Update achievements condition and improve stats tracking logic

This commit is contained in:
alexsparkes
2024-11-02 23:52:34 +00:00
parent 5856b35b1c
commit ce387117dc
12 changed files with 266 additions and 105 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -29,7 +29,7 @@ const Sidebar = memo(({ sections, currentTab, setCurrentTab }) => {
<MdOutlineWarning />
<span>{variables.getMessage('settings:reminder.message')}</span>
</div>
<div class="flex flex-row items-center gap-5">
<div className="flex flex-row items-center gap-5">
<button
className="bg-neutral-900 border-neutral-800 border-2 px-8 py-2 flex flex-row items-center gap-2 rounded"
onClick={() => window.location.reload()}

View File

@@ -59,7 +59,7 @@ function SliderComponent(props) {
{variables.getMessage('settings:buttons.reset')}
</span>
</span>
<div class="mx-auto">
<div className="mx-auto">
<Slider
value={Number(value)}
onChange={handleChange}

View File

@@ -375,7 +375,7 @@ const ItemPage = () => {
/>
</Modal>
<div className="itemPage flex flex-row gap-8 2xl:gap-16 justify-between">
<div class="flex flex-col-reverse xl:flex-row gap-8 2xl:gap-16">
<div className="flex flex-col-reverse xl:flex-row gap-8 2xl:gap-16">
<ItemDetails />
<div className="itemShowcase">
<div className="subHeader">{itemWarning()}</div>

View File

@@ -113,8 +113,8 @@ function Changelog() {
<div className="bg-modal-content-light dark:bg-modal-content-dark w-full ">
<article className="rounded p-10 prose dark:prose-invert" ref={changelog}>
<div>
<h1 class="leading-tight mb-1">{title}</h1>
<p class="leading-none mt-0">Published on {date}</p>
<h1 className="leading-tight mb-1">{title}</h1>
<p className="leading-none mt-0">Published on {date}</p>
</div>
<Markdown options={{ overrides: { a: { props: { target: '_blank' } } } }}>
{content}

View File

@@ -3,7 +3,7 @@
"id": "10tabs",
"condition": {
"type": "tabsOpened",
"amount": 3
"amount": 10
}
},
{

View File

@@ -4,13 +4,13 @@ export function checkAchievements(stats) {
achievements.forEach((achievement) => {
switch (achievement.condition.type) {
case 'tabsOpened':
if (stats['tabs-opened'] >= achievement.condition.amount) {
if (stats['tabs-opened']?.count >= achievement.condition.amount) {
achievement.achieved = true;
}
break;
case 'addonInstall':
if (stats.marketplace) {
if (stats.marketplace['install'] >= achievement.condition.amount) {
if (stats.marketplace['install']?.count >= achievement.condition.amount) {
achievement.achieved = true;
}
}
@@ -30,21 +30,18 @@ export function newAchievements(stats) {
const newAchievements = [];
checkedAchievements.forEach((achievement) => {
if (achievement.achieved && !oldAchievements.includes(achievement.id)) {
if (achievement.achieved && !oldAchievements.some((a) => a.id === achievement.id)) {
newAchievements.push(achievement);
}
});
// add timestamp to new achievements
newAchievements.forEach((achievement) => {
achievement.timestamp = Date.now();
achievement.timestamp = new Date().toISOString();
});
// save new achievements to local storage
localStorage.setItem(
'achievements',
JSON.stringify([...oldAchievements, ...newAchievements.map((achievement) => achievement.id)]),
);
localStorage.setItem('achievements', JSON.stringify([...oldAchievements, ...newAchievements]));
return newAchievements;
}

View File

@@ -5,7 +5,7 @@ import { checkAchievements, newAchievements } from './condition';
function getLocalisedAchievementData(id) {
return {
name: variables.getMessage(`achievements:${id}.name`),
description: variables.getMessage(`achievements:${id}.description`)
description: variables.getMessage(`achievements:${id}.description`),
};
}

View File

@@ -19,28 +19,70 @@ export default class Stats {
});
}
/**
* It takes three arguments, a type, a name, and an action, and then it increments the value of the name in the type
* object in localStorage
* @param type - The type of event you want to track. This can be anything you want, but I recommend
* using something like "click" or "hover"
* @param name - The name of the event.
* @param action - The action of the event.
*/
static async postEvent(type, name, action) {
static calculateNextLevelXp(level) {
return Math.floor(100 * Math.pow(1.5, level - 1));
}
static calculateXpForEvent(eventType, streak) {
let baseXp = 10; // Base XP for an event
if (eventType === 'tabs-opened') {
baseXp = 5;
}
return baseXp * (1 + streak * 0.1); // Increase XP by 10% for each day in the streak
}
static async postEvent(type, name = '', action = '') {
const data = JSON.parse(localStorage.getItem('statsData')) || {};
const timestamp = new Date().toISOString();
if (!name) {
data[type] = data[type] + 1 || 1;
} else {
data[type] = data[type] || {};
console.log(`Event Type: ${type}`);
console.log(`Event Name: ${name}`);
console.log(`Event Action: ${action}`);
if (action) {
data[type][name] = data[type][name] || {};
data[type][name][action] = data[type][name][action] + 1 || 1;
if (!data[type]) {
data[type] = { count: 0, events: [] };
}
data[type].count += 1;
data[type].events.push({ name, action, timestamp });
// Calculate XP and level
const streak = data.streak?.current || 0;
const lastEventTimestamp = data[type].events[data[type].events.length - 2]?.timestamp;
const lastEventDate = lastEventTimestamp ? new Date(lastEventTimestamp).toDateString() : null;
const currentEventDate = new Date(timestamp).toDateString();
if (lastEventDate && lastEventDate !== currentEventDate) {
const daysDifference =
(new Date(currentEventDate) - new Date(lastEventDate)) / (1000 * 60 * 60 * 24);
if (daysDifference === 1) {
data.streak = { current: streak + 1 };
} else {
data[type][name] = data[type][name] + 1 || 1;
data.streak = { current: 1 };
}
} else if (!lastEventDate) {
data.streak = { current: 1 };
}
const xpGained = this.calculateXpForEvent(type, data.streak.current);
data.totalXp = (data.totalXp || 0) + xpGained;
data.xp = (data.xp || 0) + xpGained;
data.nextLevelXp = data.nextLevelXp || this.calculateNextLevelXp(data.level || 1);
console.log(`XP Gained for ${type}: ${xpGained}`);
console.log(`Total XP: ${data.totalXp}`);
console.log(`Current Level XP: ${data.xp}`);
console.log(`Next Level XP: ${data.nextLevelXp}`);
console.log(`Current Streak: ${data.streak.current}`);
while (data.xp >= data.nextLevelXp) {
data.xp -= data.nextLevelXp;
data.level = (data.level || 1) + 1;
data.nextLevelXp = this.calculateNextLevelXp(data.level);
toast.info(`🎉 Level Up ${data.level}`, {
icon: false,
closeButton: false,
});
}
localStorage.setItem('statsData', JSON.stringify(data));

View File

@@ -1,25 +1,26 @@
/* eslint-disable array-callback-return */
import { useState, useEffect, useCallback, useMemo } from 'react';
import { MdShowChart, MdRestartAlt, MdDownload, MdAccessTime, MdLock } from 'react-icons/md';
import { FaTrophy } from 'react-icons/fa';
import { MdShowChart, MdRestartAlt, MdDownload } from 'react-icons/md';
import { toast } from 'react-toastify';
import Modal from 'react-modal';
import { Button } from 'components/Elements';
import { Header, CustomActions, PreferencesWrapper } from 'components/Layout/Settings';
import { Header, CustomActions } from 'components/Layout/Settings';
import { StatsOverview } from './sections/StatsOverview';
import { ClearModal } from './ClearModal';
import Achievements from './sections/Achievements';
import { saveFile } from 'utils/saveFile';
import variables from 'config/variables';
import {
getLocalisedAchievementData,
achievements as initialAchievements,
checkAchievements,
} from 'features/stats/api/achievements';
const Stats = () => {
const [stats, setStats] = useState(() => JSON.parse(localStorage.getItem('statsData')) || {});
const [achievements, setAchievements] = useState(initialAchievements);
const [achievements, setAchievements] = useState(
() => JSON.parse(localStorage.getItem('achievements')) || initialAchievements,
);
const [clearmodal, setClearmodal] = useState(false);
const updateAchievements = useCallback(() => {
@@ -52,25 +53,6 @@ const Stats = () => {
saveFile(JSON.stringify(stats, null, 2), filename);
};
const AchievementElement = ({ key, id, achieved, timestamp }) => {
const { name, description } = getLocalisedAchievementData(id);
return (
<div className="achievement" key={key}>
{achieved ? <FaTrophy className="trophy" /> : <MdLock className="trophyLocked" />}
<div className={'achievementContent' + (achieved ? ' achieved' : '')}>
{achieved && timestamp && (
<span className="timestamp">
<MdAccessTime /> {new Date(timestamp).toLocaleDateString()}
</span>
)}
<span className="achievementTitle">{name}</span>
<span className="subtitle">{achieved ? description : '?????'}</span>
</div>
</div>
);
};
const StatsElement = ({ title, value }) => (
<div>
<span className="subtitle">{title}</span>
@@ -82,7 +64,7 @@ const Stats = () => {
return (
<>
<Header title={variables.getMessage(`${STATS_SECTION}.title`)} report={false}>
{/* <Header title={variables.getMessage(`${STATS_SECTION}.title`)} report={false}>
<CustomActions>
<Button
type="settings"
@@ -97,7 +79,7 @@ const Stats = () => {
label={variables.getMessage('settings:buttons.reset')}
/>
</CustomActions>
</Header>
</Header> */}
<Modal
closeTimeoutMS={100}
onRequestClose={() => setClearmodal(false)}
@@ -108,6 +90,14 @@ const Stats = () => {
>
<ClearModal modalClose={() => setClearmodal(false)} resetStats={resetStats} />
</Modal>
<StatsOverview stats={stats} />
<Achievements
achievements={achievements}
getUnlockedCount={getUnlockedCount}
STATS_SECTION={STATS_SECTION}
variables={variables}
/>
<span class="text-2xl font-semibold pb-5">Statistics</span>
<div className="modalInfoPage stats">
<div className="statSection rightPanel">
<div className="statIcon">
@@ -116,23 +106,23 @@ const Stats = () => {
<div className="statGrid">
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.tabs_opened`)}
value={stats['tabs-opened'] || 0}
value={stats['tabs-opened']?.count || 0}
/>
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.backgrounds_favourited`)}
value={stats['background-favourite'] || 0}
value={stats['background-favourite']?.count || 0}
/>
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.backgrounds_downloaded`)}
value={stats.feature ? stats.feature['background-download'] || 0 : 0}
value={stats['background-download']?.count || 0}
/>
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.quotes_favourited`)}
value={stats.feature ? stats.feature['quoted-favourite'] || 0 : 0}
value={stats['quoted-favourite']?.count || 0}
/>
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.quicklinks_added`)}
value={stats.feature ? stats.feature['quicklink-add'] || 0 : 0}
value={stats['quicklink-add']?.count || 0}
/>
<StatsElement
title={variables.getMessage(`${STATS_SECTION}.sections.settings_changed`)}
@@ -142,49 +132,14 @@ const Stats = () => {
title={variables.getMessage(`${STATS_SECTION}.sections.addons_installed`)}
value={stats.marketplace?.install?.length || 0}
/>
<StatsElement
title="XP Level"
value={`${stats.level || 1} (${stats.xp || 0}/${stats.nextLevelXp || 100} XP)`}
/>
<StatsElement title="Total XP" value={`${stats.totalXp || 0} XP`} />
<StatsElement title="Streak" value={`${stats.streak?.current || 0} days`} />
</div>
</div>
<div className="statSection leftPanel">
<span className="title">{variables.getMessage(`${STATS_SECTION}.achievements`)}</span>
<br />
<span className="subtitle">
{variables.getMessage(`${STATS_SECTION}.unlocked`, {
count: `${getUnlockedCount}/${achievements.length}`,
})}
</span>
</div>
<div className="achievements">
<div className="achievementsGrid">
{achievements.map((achievement, index) => {
if (achievement.achieved) {
return (
<AchievementElement
key={index}
id={achievement.id}
achieved={achievement.achieved}
timestamp={achievement.timestamp}
/>
);
}
})}
</div>
<span className="title">{variables.getMessage(`${STATS_SECTION}.locked`)}</span>
<PreferencesWrapper>
<div className="achievementsGrid preferencesInactive">
{achievements.map((achievement, index) => {
if (!achievement.achieved) {
return (
<AchievementElement
key={index}
id={achievement.id}
achieved={achievement.achieved}
/>
);
}
})}
</div>
</PreferencesWrapper>
</div>
</div>
</>
);

View File

@@ -0,0 +1,110 @@
import React, { useState } 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 { motion } from 'framer-motion';
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
const AchievementElement = ({ achievementKey, id, achieved, timestamp }) => {
if (!id) {
return null; // Ensure id is not undefined
}
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>
)}
<span className="achievementTitle">{name}</span>
<span className="subtitle">{achieved ? description : '?????'}</span>
</div>
</div>
);
};
const Achievements = ({ achievements, getUnlockedCount, STATS_SECTION, variables }) => {
const [showLocked, setShowLocked] = useState(false);
const toggleLocked = () => {
setShowLocked(!showLocked);
};
return (
<div className="achievementsSection">
<div className="flex flex-row justify-between items-center py-5">
<span className="text-2xl font-semibold">
{variables.getMessage(`${STATS_SECTION}.achievements`)}
</span>
<span className="text-neutral-200">
{variables.getMessage(`${STATS_SECTION}.unlocked`, {
count: `${getUnlockedCount}/${achievements.length}`,
})}
</span>
</div>
<div className="achievements">
<div className="achievementsGrid">
{achievements.map((achievement, index) => {
if (achievement.achieved) {
return (
<AchievementElement
key={index}
achievementKey={index}
id={achievement.id}
achieved={achievement.achieved}
timestamp={achievement.timestamp}
/>
);
}
return null; // Ensure a value is returned
})}
</div>
<div
className="flex flex-row justify-between items-center py-5 cursor-pointer"
onClick={toggleLocked}
>
<span className="text-xl font-semibold">
{variables.getMessage(`${STATS_SECTION}.locked`)}
</span>
<span className="text-neutral-200">
{showLocked ? <FaChevronUp /> : <FaChevronDown />}
</span>
</div>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: showLocked ? 'auto' : 0, opacity: showLocked ? 1 : 0 }}
transition={{ duration: 0.5 }}
className="overflow-hidden"
>
<div className="achievementsGrid preferencesInactive">
{achievements.map((achievement, index) => {
if (!achievement.achieved) {
return (
<AchievementElement
key={index}
achievementKey={index}
id={achievement.id}
achieved={achievement.achieved}
/>
);
}
return null; // Ensure a value is returned
})}
</div>
</motion.div>
</div>
</div>
);
};
export default Achievements;

View File

@@ -0,0 +1,57 @@
const StatsOverview = ({ stats }) => {
const xpPercentage = Math.floor((stats.xp / stats.nextLevelXp) * 100);
const ProgressCircle = ({ percentage }) => {
const circleRadius = 50;
const circleCircumference = 2 * Math.PI * circleRadius;
const progress = circleCircumference - (percentage / 100) * circleCircumference;
return (
<div className="relative flex items-center justify-center w-24 h-24 p-10">
<svg className="absolute w-full h-full transform -rotate-90">
<circle
cx="50%"
cy="50%"
r={circleRadius}
stroke="currentColor"
strokeWidth="8"
className="text-gray-300"
fill="none"
/>
<circle
cx="50%"
cy="50%"
r={circleRadius}
stroke="currentColor"
strokeWidth="8"
className="text-[#D21A11]"
fill="none"
strokeDasharray={circleCircumference}
strokeDashoffset={progress}
/>
</svg>
<span className="absolute text-xl font-bold text-white">{percentage}%</span>
</div>
);
};
return (
<div class="grid grid-cols-2 gap-10">
<div className="bg-modal-content-light dark:bg-modal-content-dark py-6 px-10 rounded flex flex-row items-center">
<ProgressCircle percentage={xpPercentage} />
<div className="flex flex-col">
<span className="text-2xl font-bold">Level {stats.level || 1}</span>
<span>{`${Math.round(stats.nextLevelXp - stats.xp) || 0} XP until next level`}</span>
</div>
</div>
<div className="bg-modal-content-light dark:bg-modal-content-dark py-6 px-10 rounded flex flex-row items-center justify-center">
<div className="flex flex-col">
<span className="text-2xl font-bold">Streak</span>
<span>{`${stats.streak?.current || 0} ${stats.streak?.current === 1 ? 'day' : 'days'}`}</span>
</div>
</div>
</div>
);
};
export { StatsOverview as default, StatsOverview };