From f439ef7f109162f9cf3adcfd966cfb88d237dbbd Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Thu, 19 Dec 2024 17:00:36 +0000 Subject: [PATCH] feat(achievements): implement IndexedDB for achievement storage and retrieval --- .../stats/api/achievements/condition.js | 94 +++++++------------ src/features/stats/api/stats.js | 38 +++++--- src/features/stats/options/StatsOptions.jsx | 13 +-- .../stats/options/sections/Achievements.jsx | 88 ++++++++++++----- src/utils/indexedDB.js | 43 +++++++++ 5 files changed, 174 insertions(+), 102 deletions(-) diff --git a/src/features/stats/api/achievements/condition.js b/src/features/stats/api/achievements/condition.js index e273ba59..c45ef8da 100644 --- a/src/features/stats/api/achievements/condition.js +++ b/src/features/stats/api/achievements/condition.js @@ -1,9 +1,11 @@ import { achievements } from './index'; import statsModule from '../stats'; +import { getAchievements, saveAchievement } from 'utils/indexedDB'; export async function checkAchievements() { - // First get the events data which contains all user actions + const storedAchievements = await getAchievements(); const events = await statsModule.getStats(); + const currentTabId = statsModule.getCurrentTabId(); // Calculate aggregated stats const aggregatedStats = { @@ -13,83 +15,51 @@ export async function checkAchievements() { level: statsModule.level || 1, }; - console.log('Checking achievements with aggregated stats:', aggregatedStats); + const newlyAchieved = []; + const updatedAchievements = achievements.map((achievement) => { + const stored = storedAchievements.find((a) => a.id === achievement.id); + if (stored && stored.achieved) { + return stored; + } - achievements.forEach((achievement) => { + let achieved = false; switch (achievement.condition.type) { case 'tabsOpened': - console.log( - `Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats['new-tab']}`, - ); - if (aggregatedStats['new-tab'] >= achievement.condition.amount) { - achievement.achieved = true; - } + achieved = aggregatedStats['new-tab'] >= achievement.condition.amount; break; - case 'addonInstall': - console.log( - `Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats['marketplace-install']}`, - ); - if (aggregatedStats['marketplace-install'] >= achievement.condition.amount) { - achievement.achieved = true; - } + achieved = aggregatedStats['marketplace-install'] >= achievement.condition.amount; break; - case 'reachLevel': - console.log( - `Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats.level}`, - ); - if (aggregatedStats.level >= achievement.condition.amount) { - achievement.achieved = true; - } + achieved = aggregatedStats.level >= achievement.condition.amount; break; - case 'earnXp': - console.log( - `Checking achievement: ${achievement.id}, Condition: ${achievement.condition.type}, Required: ${achievement.condition.amount}, Current: ${aggregatedStats.totalXp}`, - ); - if (aggregatedStats.totalXp >= achievement.condition.amount) { - achievement.achieved = true; - } - break; - - default: - console.warn(`Unknown achievement condition type: ${achievement.condition.type}`); + achieved = aggregatedStats.totalXp >= achievement.condition.amount; break; } + + if (achieved && !stored?.achieved) { + const updatedAchievement = { + ...achievement, + achieved: true, + timestamp: new Date().toISOString(), + tabId: currentTabId, + }; + newlyAchieved.push(updatedAchievement); + saveAchievement(updatedAchievement); + return updatedAchievement; + } + + return achievement; }); - return achievements; + return { updatedAchievements, newlyAchieved }; } export async function newAchievements() { - // Get previously achieved achievements - const oldAchievements = JSON.parse(localStorage.getItem('achievements')) || []; - - // Check for new achievements - const checkedAchievements = await checkAchievements(); - const newAchievements = []; - - checkedAchievements.forEach((achievement) => { - const isNewAchievement = - achievement.achieved && !oldAchievements.some((a) => a.id === achievement.id); - - if (isNewAchievement) { - const newAchievement = { - ...achievement, - timestamp: new Date().toISOString(), - }; - newAchievements.push(newAchievement); - } - }); - - // Update stored achievements - if (newAchievements.length > 0) { - const updatedAchievements = [...oldAchievements, ...newAchievements]; - localStorage.setItem('achievements', JSON.stringify(updatedAchievements)); - } - - return newAchievements; + const { updatedAchievements, newlyAchieved } = await checkAchievements(); + // Only return newly achieved achievements for toast notifications + return newlyAchieved; } // Helper function to get achievement progress diff --git a/src/features/stats/api/stats.js b/src/features/stats/api/stats.js index c7d551d0..77a641f1 100644 --- a/src/features/stats/api/stats.js +++ b/src/features/stats/api/stats.js @@ -11,6 +11,8 @@ import variables from 'config/variables'; export default class Stats { static #currentTabId = null; + static #toastTimeout = null; + static #processedAchievements = new Set(); static generateTabId() { return `tab_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; @@ -24,19 +26,29 @@ export default class Stats { } static async achievementTrigger(stats) { - const newAchievement = await newAchievements(stats); - newAchievement.forEach((achievement) => { - if (achievement) { - const { name } = getLocalisedAchievementData(achievement.id); - toast.info( - `🏆 ${variables.getMessage('settings:sections.stats.achievement_unlocked', { name: name })}`, - { - icon: false, - closeButton: false, - }, - ); - } - }); + // Clear any pending achievement checks + if (this.#toastTimeout) { + clearTimeout(this.#toastTimeout); + } + + this.#toastTimeout = setTimeout(async () => { + const newlyAchieved = await newAchievements(stats); + + newlyAchieved.forEach((achievement) => { + // Only show toast if we haven't processed this achievement in this session + if (!this.#processedAchievements.has(achievement.id)) { + const { name } = getLocalisedAchievementData(achievement.id); + toast.info( + `🏆 ${variables.getMessage('settings:sections.stats.achievement_unlocked', { name })}`, + { + icon: false, + closeButton: false, + }, + ); + this.#processedAchievements.add(achievement.id); + } + }); + }, 1000); // 1 second debounce } static calculateNextLevelXp(level) { diff --git a/src/features/stats/options/StatsOptions.jsx b/src/features/stats/options/StatsOptions.jsx index 827d08dc..4bec507d 100644 --- a/src/features/stats/options/StatsOptions.jsx +++ b/src/features/stats/options/StatsOptions.jsx @@ -17,6 +17,7 @@ import { achievements as initialAchievements, checkAchievements, } from 'features/stats/api/achievements'; +import { clearAchievements } from 'utils/indexedDB'; const Stats = () => { const [stats, setStats] = useState(() => JSON.parse(localStorage.getItem('statsData')) || {}); @@ -26,8 +27,8 @@ const Stats = () => { const [clearmodal, setClearmodal] = useState(false); const updateAchievements = useCallback(async () => { - const achieved = await checkAchievements(); - setAchievements(achieved); + const { updatedAchievements } = await checkAchievements(); // Destructure the result + setAchievements(updatedAchievements); // Use the updatedAchievements array }, []); useEffect(() => { @@ -38,7 +39,7 @@ const Stats = () => { return achievements.filter((achievement) => achievement.achieved).length; }, [achievements]); - const resetStats = () => { + const resetStats = async () => { const emptyStats = { level: 1, totalXp: 0, @@ -48,12 +49,12 @@ const Stats = () => { }; localStorage.setItem('statsData', JSON.stringify(emptyStats)); localStorage.setItem('eventLog', JSON.stringify([])); - localStorage.setItem('achievements', JSON.stringify(initialAchievements)); + await clearAchievements(); // Clear achievements from IndexedDB setStats(emptyStats); setClearmodal(false); toast(variables.getMessage('toasts.stats_reset')); - updateAchievements(); // Call updateAchievements to refresh achievements after reset - Stats.clearSessionStats(); // Clear session stats when resetting + updateAchievements(); + Stats.clearSessionStats(); }; const downloadStats = () => { diff --git a/src/features/stats/options/sections/Achievements.jsx b/src/features/stats/options/sections/Achievements.jsx index 89f3ccb4..861454ef 100644 --- a/src/features/stats/options/sections/Achievements.jsx +++ b/src/features/stats/options/sections/Achievements.jsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { FaTrophy } from 'react-icons/fa'; import { MdAccessTime, MdLock } from 'react-icons/md'; import { PreferencesWrapper } from 'components/Layout/Settings'; import { getLocalisedAchievementData } from 'features/stats/api/achievements'; +import { achievements as initialAchievements } from 'features/stats/api/achievements'; import { motion } from 'framer-motion'; import { FaChevronDown, FaChevronUp } from 'react-icons/fa'; @@ -14,20 +15,43 @@ const AchievementElement = ({ achievementKey, id, achieved, timestamp }) => { const { name, description } = getLocalisedAchievementData(id); return ( -
- {achieved ? ( - - ) : ( - - )} -
- {achieved && timestamp && ( - - {new Date(timestamp).toLocaleDateString()} - +
+
+
+ {achieved ? ( + + ) : ( + )} - {name} - +
+
+
+ + {name} + + {achieved && timestamp && ( + + + {new Date(timestamp).toLocaleDateString()} + + )} +
+ {achieved ? description : '?????'}
@@ -37,10 +61,32 @@ const AchievementElement = ({ achievementKey, id, achieved, timestamp }) => { const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables }) => { const [showLocked, setShowLocked] = useState(false); + const [allAchievements, setAllAchievements] = useState([]); - const toggleLocked = () => { - setShowLocked(!showLocked); - }; + useEffect(() => { + // Combine stored achievements with initial achievements to ensure we have all of them + const combineAchievements = () => { + // Start with all possible achievements + const combined = initialAchievements.map((achievement) => { + // Find if this achievement exists in the provided achievements array + const stored = achievements.find((a) => a.id === achievement.id); + if (stored) { + // If it exists in stored achievements, use that data + return stored; + } + // If it doesn't exist in stored achievements, use the initial data + return { + ...achievement, + achieved: false, + }; + }); + setAllAchievements(combined); + }; + + combineAchievements(); + }, [achievements]); + + const totalAchievements = initialAchievements.length; // Get total from initial achievements return (
@@ -50,13 +96,13 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables {variables.getMessage(`${STATS_SECTION}.unlocked`, { - count: `${getUnlockedCount}/${achievements.length}`, + count: `${getUnlockedCount}/${totalAchievements}`, // Use total from initialAchievements })}
- {achievements.map((achievement, index) => { + {allAchievements.map((achievement, index) => { if (achievement.achieved) { return (
setShowLocked(!showLocked)} > {variables.getMessage(`${STATS_SECTION}.locked`)} @@ -89,7 +135,7 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables className="overflow-hidden" >
- {achievements.map((achievement, index) => { + {allAchievements.map((achievement, index) => { if (!achievement.achieved) { return ( { return new Promise((resolve, reject) => { @@ -27,6 +28,11 @@ export const openDB = () => { if (!db.objectStoreNames.contains(SESSION_STORE_NAME)) { db.createObjectStore(SESSION_STORE_NAME, { keyPath: 'tabId' }); } + + // Create achievements store if it doesn't exist + if (!db.objectStoreNames.contains(ACHIEVEMENTS_STORE)) { + db.createObjectStore(ACHIEVEMENTS_STORE, { keyPath: 'id' }); + } }; request.onsuccess = (event) => { @@ -169,3 +175,40 @@ export const clearSessionStats = () => { }); }); }; + +// Add new methods for achievements +export const saveAchievement = async (achievement) => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readwrite'); + const store = transaction.objectStore(ACHIEVEMENTS_STORE); + const request = store.put(achievement); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +export const getAchievements = async () => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readonly'); + const store = transaction.objectStore(ACHIEVEMENTS_STORE); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +export const clearAchievements = async () => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([ACHIEVEMENTS_STORE], 'readwrite'); + const store = transaction.objectStore(ACHIEVEMENTS_STORE); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +};