diff --git a/badges.js b/badges.js index 20c8205..c3d543f 100644 --- a/badges.js +++ b/badges.js @@ -1,176 +1,86 @@ /* - * badges.js β Unified βAchievements & Badgesβ system (Milestones) + * badges.js β Badge System 2.0 (Rules-based achievement unlocking) * - * 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 + * Requirements implemented: + * - Rule schema: { id, title, condition: { type, params }, reward } + * - Achievement evaluator runs via existing call-sites: + * window.achievements.checkAndNotify() (triggered after quiz + mastery updates) + * - State: + * unlockedBadges: [] + * badgeProgress: { [badgeId]: {...} } + * - UI: + * locked vs unlocked + progress toward badge + * - Persistence: 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 QUIZ_PROGRESS_KEY = "learnsphere_quiz_progress_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" - }; - } - }, + // --------------------------- + // 1) Badge rule schema + // --------------------------- + const BADGE_RULES = [ { - 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: "three_day_streak", - title: "3-day practice streak", - description: "Practice every day for 3 days.", - icon: "β‘", - getProgress: (stats) => { - const target = 3; - const done = stats.currentStreak || 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: "perfect_3_quizzes_in_week", + title: "3 perfect quizzes in a week", + icon: "π", + reward: { type: "badge" }, + condition: { + type: "perfect_quizzes_in_week", + params: { targetPerfectQuizzes: 3, window: "week", perfectAccuracy: 1.0 } + }, + description: "Achieve 100% accuracy in 3 quizzes within the same week." }, { - id: "fourteen_day_streak", - title: "14-day practice streak", - description: "Practice every day for 14 days.", - icon: "π", - getProgress: (stats) => { - const target = 14; - const done = stats.currentStreak || 0; - return { - unlocked: done >= target, - progressText: `${Math.min(done, target)}/${target}` - }; - } - }, - { - id: "weekend_warrior", - title: "Weekend Warrior", - description: "Complete a quiz on a weekend (Saturday or Sunday).", - icon: "βοΈ", - getProgress: (stats) => { - const unlocked = !!stats.hasWeekendAttempt; - return { - unlocked, - progressText: unlocked ? "Unlocked" : "0/1" - }; - } - }, - { - id: "weekly_badge", - title: "Weekly Scholar", - description: "Complete at least one quiz this week.", - icon: "π", - getProgress: (stats) => { - const unlocked = !!stats.hasWeeklyAttempt; - return { - unlocked, - progressText: unlocked ? "Unlocked" : "0/1" - }; - } - }, - { - id: "ninety_percent_accuracy", - title: "90%+ accuracy", - description: "Maintain 90% accuracy across your attempts.", + id: "mastery_gt_80_any_skill", + title: "Mastery > 80% in any skill", 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; - - let progressText; - if (total <= 0 || typeof acc !== "number") { - progressText = "No attempts"; - } else { - progressText = `${Math.round(acc * 100)}%`; - } - - return { - unlocked, - progressText - }; - } - }, - { - id: "daily_goal_hero", - title: "Daily Goal Hero", - description: "Complete your daily learning goal today.", - icon: "π₯", - getProgress: (stats) => { - const unlocked = stats.dailyGoalCompleted || false; - return { - unlocked, - progressText: unlocked ? "Unlocked" : "0/1" - }; - } + reward: { type: "badge" }, + condition: { + type: "mastery_threshold_any_skill", + params: { threshold: 0.8 } + }, + description: "Reach at least 80% accuracy in any skill." } ]; - + // --------------------------- + // 2) Persistence + // --------------------------- function loadAchievements() { try { const raw = localStorage.getItem(ACHIEVEMENTS_KEY); - if (!raw) return { unlocked: {} }; + if (!raw) { + return { unlockedBadges: [], badgeProgress: {} }; + } const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return { unlocked: {} }; - if (!parsed.unlocked || typeof parsed.unlocked !== "object") parsed.unlocked = {}; + if (!parsed || typeof parsed !== "object") { + return { unlockedBadges: [], badgeProgress: {} }; + } + if (!Array.isArray(parsed.unlockedBadges)) parsed.unlockedBadges = []; + if (!parsed.badgeProgress || typeof parsed.badgeProgress !== "object") parsed.badgeProgress = {}; return parsed; } catch { - return { unlocked: {} }; + return { unlockedBadges: [], badgeProgress: {} }; } } - function saveAchievements(ach) { + function saveAchievements(data) { try { - localStorage.setItem(ACHIEVEMENTS_KEY, JSON.stringify(ach)); + localStorage.setItem(ACHIEVEMENTS_KEY, JSON.stringify(data)); } catch (e) { console.warn("LearnSphere: Could not save achievements.", e); } } - function safeNumber(n) { - return typeof n === "number" && !Number.isNaN(n) ? n : null; + // --------------------------- + // 3) Date helpers + // --------------------------- + function safeDateKey(d) { + const dt = new Date(d); + if (Number.isNaN(dt.getTime())) return null; + return dt.toISOString(); } function getWeekNumber(date) { @@ -182,111 +92,187 @@ return `${d.getFullYear()}-W${weekNo}`; } - function buildStatsFromQuizProgress() { - if (!window.quizProgress) { - return { - attemptCount: 0, - topicAttemptedCount: 0, - currentStreak: 0, - overallAccuracy: null, - overallTotalAnswers: 0, - hasWeekendAttempt: false, - hasWeeklyAttempt: false - }; + // --------------------------- + // 4) Stats extraction + // --------------------------- + function getQuizAttemptsRaw() { + try { + const raw = localStorage.getItem(QUIZ_PROGRESS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed.attempts) ? parsed.attempts : []; + } catch { + return []; } + } - // Streak - let currentStreak = 0; - if (window.studyProgress && typeof window.studyProgress.loadStreakState === "function") { - currentStreak = window.studyProgress.loadStreakState().currentStreak || 0; - } else if (window.quizProgress && typeof window.quizProgress.getStreak === "function") { - const streak = window.quizProgress.getStreak(); - currentStreak = streak.currentStreak || 0; + function getPerfectAttemptsInWindow({ windowType = "week", perfectAccuracy = 1.0 } = {}) { + const attempts = getQuizAttemptsRaw(); + if (!attempts.length) { + return { perfectCount: 0, totalConsidered: 0, perfectAttemptDates: [] }; } - // Daily Goal - let dailyGoalCompleted = false; - if (window.studyProgress && typeof window.studyProgress.loadStreakState === "function") { - const state = window.studyProgress.loadStreakState(); - const qDone = state.dailyGoalProgress.quizzesCompleted || 0; - const rDone = state.dailyGoalProgress.questionsReviewed || 0; - if (qDone >= 1 || rDone >= 10) { - dailyGoalCompleted = true; + const now = new Date(); + const currentWindowKey = windowType === "week" ? getWeekNumber(now) : null; + + let perfectCount = 0; + let totalConsidered = 0; + const perfectAttemptDates = []; + + for (const a of attempts) { + if (!a || !a.finishedAt) continue; + if (typeof a.accuracy !== "number" || Number.isNaN(a.accuracy)) continue; + + if (windowType === "week") { + if (getWeekNumber(a.finishedAt) !== currentWindowKey) continue; + } + + // Considered (within window) + totalConsidered++; + + const isPerfect = a.accuracy >= perfectAccuracy; + if (isPerfect) { + perfectCount++; + perfectAttemptDates.push(a.finishedAt); } - } else { - const STREAK_KEY = "learnsphere_streak_state_v1"; - try { - const raw = localStorage.getItem(STREAK_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed && parsed.dailyGoalProgress) { - const qDone = parsed.dailyGoalProgress.quizzesCompleted || 0; - const rDone = parsed.dailyGoalProgress.questionsReviewed || 0; - if (qDone >= 1 || rDone >= 10) { - dailyGoalCompleted = true; - } - } + } + + return { perfectCount, totalConsidered, perfectAttemptDates }; + } + + function getAnySkillMasteryAccuracy() { + // Uses quizProgress.js mastery stats + if (!window.quizProgress || typeof window.quizProgress.getMasteryStats !== "function") { + return { bestAccuracy: null, bestSkillId: null }; + } + + const mastery = window.quizProgress.getMasteryStats(); + if (!mastery || typeof mastery !== "object") { + return { bestAccuracy: null, bestSkillId: null }; + } + + let bestAccuracy = null; + let bestSkillId = null; + + for (const [skillId, m] of Object.entries(mastery)) { + const attempts = m?.attempts || 0; + const correct = m?.correct || 0; + if (!attempts || attempts <= 0) continue; + const acc = correct / attempts; + if (typeof acc === "number" && !Number.isNaN(acc)) { + if (bestAccuracy == null || acc > bestAccuracy) { + bestAccuracy = acc; + bestSkillId = skillId; } - } catch (e) {} + } } + return { bestAccuracy, bestSkillId }; + } - // Overall accuracy - const overall = window.quizProgress.getOverallAccuracy ? window.quizProgress.getOverallAccuracy() : { accuracy: null, total: 0 }; + // --------------------------- + // 5) Rule evaluator + progress + // --------------------------- + function evaluateBadge(rule, derived) { + const { type, params } = rule.condition || {}; - // Topic completion proxy - 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++; + // Progress objects are used for UI. unlocked is boolean. + if (type === "perfect_quizzes_in_week") { + const target = Number(params?.targetPerfectQuizzes) || 0; + const perfectAccuracy = typeof params?.perfectAccuracy === "number" ? params.perfectAccuracy : 1.0; + const windowType = params?.window || "week"; + + const { perfectCount } = derived.perfectAttemptsInWindow; + const pct = target > 0 ? Math.min(1, perfectCount / target) : 0; + return { + unlocked: perfectCount >= target && target > 0, + progress: { + kind: "count", + current: perfectCount, + target, + percent: pct + }, + progressText: target > 0 ? `${Math.min(perfectCount, target)}/${target}` : "β" + }; } - let attemptCount = 0; - for (const tId of Object.keys(byTopic || {})) { - attemptCount += (byTopic[tId]?.attempts || 0); + if (type === "mastery_threshold_any_skill") { + const threshold = typeof params?.threshold === "number" ? params.threshold : 0.8; + const bestAccuracy = derived.anySkillBestAccuracy.bestAccuracy; + const bestSkillId = derived.anySkillBestAccuracy.bestSkillId; + + const safeBest = typeof bestAccuracy === "number" && !Number.isNaN(bestAccuracy) ? bestAccuracy : 0; + const pct = Math.min(1, safeBest / threshold); + const unlocked = typeof bestAccuracy === "number" && bestAccuracy >= threshold && safeBest > 0; + + return { + unlocked, + progress: { + kind: "ratio", + current: safeBest, + target: threshold, + percent: pct, + bestSkillId + }, + progressText: typeof bestAccuracy === "number" && !Number.isNaN(bestAccuracy) + ? `${Math.round(bestAccuracy * 100)}% / ${Math.round(threshold * 100)}%` + : `0% / ${Math.round(threshold * 100)}%` + }; } - // Load raw attempts from localStorage for weekend/weekly checks - let attempts = []; - try { - const raw = localStorage.getItem(QUIZ_PROGRESS_KEY); - if (raw) { - attempts = JSON.parse(raw).attempts || []; - } - } catch (e) {} - - const currentWeek = getWeekNumber(new Date()); - let hasWeekendAttempt = false; - let hasWeeklyAttempt = false; - - attempts.forEach(a => { - if (!a.finishedAt) return; - const d = new Date(a.finishedAt); - const day = d.getDay(); - if (day === 0 || day === 6) { - hasWeekendAttempt = true; - } - if (getWeekNumber(a.finishedAt) === currentWeek) { - hasWeeklyAttempt = true; - } - }); + return { + unlocked: false, + progress: { kind: "unknown", current: 0, target: 0, percent: 0 }, + progressText: "β" + }; + } + function buildDerived() { return { - attemptCount, - topicAttemptedCount, - currentStreak: safeNumber(currentStreak) ?? 0, - dailyGoalCompleted, - overallAccuracy: overall?.accuracy == null ? null : safeNumber(overall.accuracy), - overallTotalAnswers: safeNumber(overall?.total) ?? 0, - hasWeekendAttempt, - hasWeeklyAttempt + perfectAttemptsInWindow: getPerfectAttemptsInWindow({ + windowType: "week", + perfectAccuracy: 1.0 + }), + anySkillBestAccuracy: getAnySkillMasteryAccuracy() }; } + function getUnlockedSet(ach) { + const set = new Set(Array.isArray(ach.unlockedBadges) ? ach.unlockedBadges : []); + return set; + } + + function unlockBadgeIfNeeded(ach, rule, evaluation) { + const unlockedSet = getUnlockedSet(ach); + if (unlockedSet.has(rule.id)) { + return { changed: false, unlockedNow: false }; + } + + if (evaluation.unlocked) { + ach.unlockedBadges.push(rule.id); + if (!ach.badgeProgress) ach.badgeProgress = {}; + ach.badgeProgress[rule.id] = { + ...evaluation.progress, + unlockedAt: new Date().toISOString(), + progressText: evaluation.progressText + }; + return { changed: true, unlockedNow: true }; + } + + // Not unlocked: still update progress cache + if (!ach.badgeProgress) ach.badgeProgress = {}; + ach.badgeProgress[rule.id] = { + ...evaluation.progress, + unlockedAt: ach.badgeProgress?.[rule.id]?.unlockedAt || null, + progressText: evaluation.progressText + }; + + return { changed: true, unlockedNow: false }; + } + // --------------------------- + // 6) Toast + // --------------------------- function ensureToastStyles() { if (document.getElementById("badge-toast-styles")) return; const style = document.createElement("style"); @@ -318,9 +304,7 @@ animation: toast-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; transition: opacity 0.3s ease, transform 0.3s ease; } - .badge-toast.fade-out { - animation: toast-fade-out 0.3s ease forwards; - } + .badge-toast.fade-out { animation: toast-fade-out 0.3s ease forwards; } .badge-toast-icon { font-size: 28px; background: rgba(102, 252, 241, 0.1); @@ -333,10 +317,6 @@ border: 1px solid rgba(102, 252, 241, 0.25); flex-shrink: 0; } - .badge-toast-details { - flex: 1; - min-width: 0; - } .badge-toast-title { color: #66fcf1; font-weight: 800; @@ -351,32 +331,20 @@ line-height: 1.3; } @keyframes toast-slide-in { - from { - opacity: 0; - transform: translateY(20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } + from { opacity: 0; transform: translateY(20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes toast-fade-out { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-20px) scale(0.95); } } `; document.head.appendChild(style); } - function showUnlockToast(badge) { + function showUnlockToast(rule) { ensureToastStyles(); - + let container = document.getElementById("badge-toast-container"); if (!container) { container = document.createElement("div"); @@ -384,97 +352,56 @@ container.className = "badge-toast-container"; document.body.appendChild(container); } - + const toast = document.createElement("div"); toast.className = "badge-toast"; toast.setAttribute("role", "alert"); - + toast.innerHTML = ` -
+${badge.title}: ${badge.description}
+${rule.title}: ${rule.description || ""}