feat(achievements): implement IndexedDB for achievement storage and retrieval

This commit is contained in:
alexsparkes
2024-12-19 17:00:36 +00:00
parent 59205cf9bf
commit f439ef7f10
5 changed files with 174 additions and 102 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 = () => {

View File

@@ -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 (
<div className="achievement" key={achievementKey}>
{achieved ? (
<FaTrophy className="trophy flex-shrink-0" />
) : (
<MdLock className="trophyLocked flex-shrink-0" />
)}
<div className={'achievementContent' + (achieved ? ' achieved' : '')}>
{achieved && timestamp && (
<span className="timestamp">
<MdAccessTime /> {new Date(timestamp).toLocaleDateString()}
</span>
<div
className="relative flex items-center gap-6 p-6 rounded-xl backdrop-blur-sm bg-neutral-900/30 border border-neutral-800/50
hover:bg-neutral-800/40 transition-all duration-300 group overflow-hidden
shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)] hover:shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)] cursor-pointer"
key={achievementKey}
>
<div
className={`absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300
${achieved ? 'bg-gradient-to-r from-yellow-500/10 to-amber-500/10' : 'bg-gradient-to-r from-neutral-700/10 to-neutral-600/10'}`}
/>
<div
className={`relative flex-shrink-0 p-3 rounded-lg
${achieved ? 'bg-gradient-to-br from-yellow-500/20 to-amber-600/20' : 'bg-neutral-800/50'}
shadow-xl transition-transform duration-300 group-hover:scale-110`}
>
{achieved ? (
<FaTrophy className="w-8 h-8 text-yellow-500 drop-shadow-[0_0_8px_rgba(234,179,8,0.3)]" />
) : (
<MdLock className="w-8 h-8 text-neutral-400" />
)}
<span className="text-base">{name}</span>
<span className="text-xs text-neutral-400 leading-tight lowercase">
</div>
<div className="flex flex-col flex-grow">
<div className="flex justify-between items-center">
<span
className={`text-lg font-bold tracking-tight
${achieved ? 'text-neutral-100' : 'text-neutral-400'}`}
>
{name}
</span>
{achieved && timestamp && (
<span className="text-xs text-neutral-500 flex items-center gap-1 bg-neutral-800/50 px-2 py-1 rounded-full">
<MdAccessTime className="w-3 h-3" />
{new Date(timestamp).toLocaleDateString()}
</span>
)}
</div>
<span className="text-sm text-neutral-400 leading-relaxed relative group-hover:text-neutral-300 transition-colors duration-300">
{achieved ? description : '?????'}
</span>
</div>
@@ -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 (
<div className="achievementsSection">
@@ -50,13 +96,13 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
</span>
<span className="text-neutral-200">
{variables.getMessage(`${STATS_SECTION}.unlocked`, {
count: `${getUnlockedCount}/${achievements.length}`,
count: `${getUnlockedCount}/${totalAchievements}`, // Use total from initialAchievements
})}
</span>
</div>
<div className="achievements">
<div className="achievementsGrid">
{achievements.map((achievement, index) => {
{allAchievements.map((achievement, index) => {
if (achievement.achieved) {
return (
<AchievementElement
@@ -73,7 +119,7 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
</div>
<div
className="flex flex-row justify-between items-center py-5 cursor-pointer"
onClick={toggleLocked}
onClick={() => setShowLocked(!showLocked)}
>
<span className="text-xl font-semibold">
{variables.getMessage(`${STATS_SECTION}.locked`)}
@@ -89,7 +135,7 @@ const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables
className="overflow-hidden"
>
<div className="achievementsGrid preferencesInactive">
{achievements.map((achievement, index) => {
{allAchievements.map((achievement, index) => {
if (!achievement.achieved) {
return (
<AchievementElement

View File

@@ -3,6 +3,7 @@ const DB_VERSION = 4; // Increment version for new store
const STORE_NAME = 'eventLog';
const CUSTOM_STORE_NAME = 'customImages';
const SESSION_STORE_NAME = 'sessionStats';
const ACHIEVEMENTS_STORE = 'achievements';
export const openDB = () => {
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);
});
};