diff --git a/src/App.jsx b/src/App.jsx index 96cc093c..0916c697 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -52,7 +52,8 @@ const App = () => { setShowBackground(true); } - // Post event immediately when the component mounts + // Reset tab ID when component mounts and post initial event + Stats.generateTabId(); Stats.postEvent('new-tab', 'tab', 'opened'); }, []); diff --git a/src/features/stats/api/stats.js b/src/features/stats/api/stats.js index 3b3ed309..c7d551d0 100644 --- a/src/features/stats/api/stats.js +++ b/src/features/stats/api/stats.js @@ -1,9 +1,28 @@ -import { addEvent, getEvents } from 'utils/indexedDB'; +import { + addEvent, + getEvents, + updateSessionStats, + getAllSessionStats, + clearSessionStats, +} from 'utils/indexedDB'; import { newAchievements, getLocalisedAchievementData } from './achievements'; import { toast } from 'react-toastify'; import variables from 'config/variables'; export default class Stats { + static #currentTabId = null; + + static generateTabId() { + return `tab_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + } + + static getCurrentTabId() { + if (!this.#currentTabId) { + this.#currentTabId = this.generateTabId(); + } + return this.#currentTabId; + } + static async achievementTrigger(stats) { const newAchievement = await newAchievements(stats); newAchievement.forEach((achievement) => { @@ -115,6 +134,45 @@ export default class Stats { data.streak.highest = highestStreak; } + static async updateSessionStats(event) { + const { tabId, timestamp, xpGained } = event; + let session = await this.getSessionStats(tabId); + + if (!session) { + session = { + tabId, + startTime: timestamp, + endTime: timestamp, + duration: 0, + eventCount: 0, + totalXp: 0, + averageXpPerEvent: 0, + }; + } + + session.endTime = timestamp; + session.duration = new Date(session.endTime) - new Date(session.startTime); + session.eventCount += 1; + session.totalXp += xpGained; + session.averageXpPerEvent = session.totalXp / session.eventCount; + + await updateSessionStats(session); + return session; + } + + static async getSessionStats(tabId) { + const sessions = await getAllSessionStats(); + return sessions.find((session) => session.tabId === tabId); + } + + static getAllSessionStats() { + return getAllSessionStats(); + } + + static clearSessionStats() { + return clearSessionStats(); + } + static async postEvent(type, name = '', action = '') { const eventLog = await getEvents(); const statsData = JSON.parse(localStorage.getItem('statsData')) || { @@ -125,13 +183,17 @@ export default class Stats { streak: { current: 0, highest: 0 }, }; const timestamp = new Date().toISOString(); + const tabId = this.getCurrentTabId(); console.log(`Event Type: ${type}`); console.log(`Event Name: ${name}`); console.log(`Event Action: ${action}`); + console.log(`Tab ID: ${tabId}`); const xpGained = this.calculateXpForEvent(type, statsData.streak.current); - await this.addEvent({ type, name, action, timestamp, xpGained }); + const event = { type, name, action, timestamp, xpGained, tabId }; + await this.addEvent(event); + await this.updateSessionStats(event); // Now awaits the IndexedDB operation this.updateStatsData(statsData, xpGained); await this.calculateStreak(statsData); @@ -141,7 +203,7 @@ export default class Stats { await this.achievementTrigger(statsData); } - static async getStats(type, name, action, startDate, endDate) { + static async getStats(type, name, action, startDate, endDate, tabId) { const eventLog = await getEvents(); return eventLog.filter((event) => { const eventDate = new Date(event.timestamp); @@ -150,7 +212,8 @@ export default class Stats { (!name || event.name === name) && (!action || event.action === action) && (!startDate || eventDate >= new Date(startDate)) && - (!endDate || eventDate <= new Date(endDate)) + (!endDate || eventDate <= new Date(endDate)) && + (!tabId || event.tabId === tabId) ); }); } diff --git a/src/features/stats/components/SessionStats.jsx b/src/features/stats/components/SessionStats.jsx new file mode 100644 index 00000000..37f4723b --- /dev/null +++ b/src/features/stats/components/SessionStats.jsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'react'; +import { MdArrowUpward, MdArrowDownward } from 'react-icons/md'; +import Stats from '../api/stats'; +import variables from 'config/variables'; + +const SessionStats = () => { + const [sessions, setSessions] = useState([]); + const [orderBy, setOrderBy] = useState('startTime'); + const [order, setOrder] = useState('desc'); + + useEffect(() => { + const loadSessions = async () => { + const stats = await Stats.getAllSessionStats(); + setSessions(stats); + }; + loadSessions(); + + // Optional: Set up periodic refresh if needed + const refreshInterval = setInterval(loadSessions, 30000); // Refresh every 30 seconds + return () => clearInterval(refreshInterval); + }, []); + + const formatDuration = (ms) => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + }; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleString(); + }; + + const handleSort = (property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const sortedSessions = [...sessions].sort((a, b) => { + const multiplier = order === 'asc' ? 1 : -1; + switch (orderBy) { + case 'startTime': + return multiplier * (new Date(a.startTime) - new Date(b.startTime)); + case 'duration': + return multiplier * (a.duration - b.duration); + case 'eventCount': + return multiplier * (a.eventCount - b.eventCount); + case 'totalXp': + return multiplier * (a.totalXp - b.totalXp); + default: + return 0; + } + }); + + const SortButton = ({ active, direction, onClick, children }) => ( + + ); + + return ( +
+ + + + + + + + + + + {sortedSessions.length === 0 ? ( + + + + ) : ( + sortedSessions.map((session) => ( + + + + + + + )) + )} + +
+ handleSort('startTime')} + > + Session Start + + + handleSort('duration')} + > + Duration + + + handleSort('eventCount')} + > + Events + + + handleSort('totalXp')} + > + XP Gained + +
+ No sessions recorded yet +
{formatDate(session.startTime)}{formatDuration(session.duration)}{session.eventCount}{session.totalXp}
+
+ ); +}; + +export default SessionStats; diff --git a/src/features/stats/options/StatsOptions.jsx b/src/features/stats/options/StatsOptions.jsx index 67f6793a..827d08dc 100644 --- a/src/features/stats/options/StatsOptions.jsx +++ b/src/features/stats/options/StatsOptions.jsx @@ -9,6 +9,7 @@ import { StatsOverview } from './sections/StatsOverview'; import { ClearModal } from './ClearModal'; import Achievements from './sections/Achievements'; import StatsDashboard from './sections/StatsDashboard'; +import SessionStats from '../components/SessionStats'; import { saveFile } from 'utils/saveFile'; import variables from 'config/variables'; @@ -52,6 +53,7 @@ const Stats = () => { setClearmodal(false); toast(variables.getMessage('toasts.stats_reset')); updateAchievements(); // Call updateAchievements to refresh achievements after reset + Stats.clearSessionStats(); // Clear session stats when resetting }; const downloadStats = () => { @@ -99,6 +101,9 @@ const Stats = () => { variables={variables} /> +
+ +
); }; diff --git a/src/utils/indexedDB.js b/src/utils/indexedDB.js index 5866fb86..a4550ff6 100644 --- a/src/utils/indexedDB.js +++ b/src/utils/indexedDB.js @@ -1,7 +1,8 @@ const DB_NAME = 'StatsDB'; -const DB_VERSION = 2; // Incremented version +const DB_VERSION = 4; // Increment version for new store const STORE_NAME = 'eventLog'; const CUSTOM_STORE_NAME = 'customImages'; +const SESSION_STORE_NAME = 'sessionStats'; export const openDB = () => { return new Promise((resolve, reject) => { @@ -10,11 +11,22 @@ export const openDB = () => { request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { + // Add tabId to the schema db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); } if (!db.objectStoreNames.contains(CUSTOM_STORE_NAME)) { db.createObjectStore(CUSTOM_STORE_NAME, { keyPath: 'id', autoIncrement: true }); } + + // Add tabId index to eventLog store if it exists + const store = event.target.transaction.objectStore(STORE_NAME); + if (!store.indexNames.contains('tabId')) { + store.createIndex('tabId', 'tabId', { unique: false }); + } + + if (!db.objectStoreNames.contains(SESSION_STORE_NAME)) { + db.createObjectStore(SESSION_STORE_NAME, { keyPath: 'tabId' }); + } }; request.onsuccess = (event) => { @@ -117,3 +129,43 @@ export const deleteCustomImage = (id) => { }); }); }; + +// Add new methods for session stats +export const updateSessionStats = (session) => { + return openDB().then((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction([SESSION_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(SESSION_STORE_NAME); + const request = store.put(session); + + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(event.target.error); + }); + }); +}; + +export const getAllSessionStats = () => { + return openDB().then((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction([SESSION_STORE_NAME], 'readonly'); + const store = transaction.objectStore(SESSION_STORE_NAME); + const request = store.getAll(); + + request.onsuccess = (event) => resolve(event.target.result); + request.onerror = (event) => reject(event.target.error); + }); + }); +}; + +export const clearSessionStats = () => { + return openDB().then((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction([SESSION_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(SESSION_STORE_NAME); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = (event) => reject(event.target.error); + }); + }); +};