feat(stats): local sessions

This commit is contained in:
alexsparkes
2024-12-18 22:33:28 +00:00
parent 85a9150be1
commit 59205cf9bf
5 changed files with 266 additions and 6 deletions

View File

@@ -52,7 +52,8 @@ const App = () => {
setShowBackground(true); 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'); Stats.postEvent('new-tab', 'tab', 'opened');
}, []); }, []);

View File

@@ -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 { newAchievements, getLocalisedAchievementData } from './achievements';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import variables from 'config/variables'; import variables from 'config/variables';
export default class Stats { 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) { static async achievementTrigger(stats) {
const newAchievement = await newAchievements(stats); const newAchievement = await newAchievements(stats);
newAchievement.forEach((achievement) => { newAchievement.forEach((achievement) => {
@@ -115,6 +134,45 @@ export default class Stats {
data.streak.highest = highestStreak; 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 = '') { static async postEvent(type, name = '', action = '') {
const eventLog = await getEvents(); const eventLog = await getEvents();
const statsData = JSON.parse(localStorage.getItem('statsData')) || { const statsData = JSON.parse(localStorage.getItem('statsData')) || {
@@ -125,13 +183,17 @@ export default class Stats {
streak: { current: 0, highest: 0 }, streak: { current: 0, highest: 0 },
}; };
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const tabId = this.getCurrentTabId();
console.log(`Event Type: ${type}`); console.log(`Event Type: ${type}`);
console.log(`Event Name: ${name}`); console.log(`Event Name: ${name}`);
console.log(`Event Action: ${action}`); console.log(`Event Action: ${action}`);
console.log(`Tab ID: ${tabId}`);
const xpGained = this.calculateXpForEvent(type, statsData.streak.current); 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); this.updateStatsData(statsData, xpGained);
await this.calculateStreak(statsData); await this.calculateStreak(statsData);
@@ -141,7 +203,7 @@ export default class Stats {
await this.achievementTrigger(statsData); 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(); const eventLog = await getEvents();
return eventLog.filter((event) => { return eventLog.filter((event) => {
const eventDate = new Date(event.timestamp); const eventDate = new Date(event.timestamp);
@@ -150,7 +212,8 @@ export default class Stats {
(!name || event.name === name) && (!name || event.name === name) &&
(!action || event.action === action) && (!action || event.action === action) &&
(!startDate || eventDate >= new Date(startDate)) && (!startDate || eventDate >= new Date(startDate)) &&
(!endDate || eventDate <= new Date(endDate)) (!endDate || eventDate <= new Date(endDate)) &&
(!tabId || event.tabId === tabId)
); );
}); });
} }

View File

@@ -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 }) => (
<button
onClick={onClick}
className="flex items-center gap-1 text-sm font-medium text-white/80 hover:text-white"
>
{children}
{active && (direction === 'asc' ? <MdArrowUpward /> : <MdArrowDownward />)}
</button>
);
return (
<div className="w-full overflow-x-auto rounded-lg border border-[#484848] bg-white/5">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[#484848]">
<th className="p-4 text-left">
<SortButton
active={orderBy === 'startTime'}
direction={order}
onClick={() => handleSort('startTime')}
>
Session Start
</SortButton>
</th>
<th className="p-4 text-left">
<SortButton
active={orderBy === 'duration'}
direction={order}
onClick={() => handleSort('duration')}
>
Duration
</SortButton>
</th>
<th className="p-4 text-left">
<SortButton
active={orderBy === 'eventCount'}
direction={order}
onClick={() => handleSort('eventCount')}
>
Events
</SortButton>
</th>
<th className="p-4 text-left">
<SortButton
active={orderBy === 'totalXp'}
direction={order}
onClick={() => handleSort('totalXp')}
>
XP Gained
</SortButton>
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#484848]">
{sortedSessions.length === 0 ? (
<tr>
<td colSpan="4" className="p-4 text-center text-white/60">
No sessions recorded yet
</td>
</tr>
) : (
sortedSessions.map((session) => (
<tr key={session.tabId} className="transition-colors hover:bg-white/5">
<td className="p-4 text-sm">{formatDate(session.startTime)}</td>
<td className="p-4 text-sm">{formatDuration(session.duration)}</td>
<td className="p-4 text-sm">{session.eventCount}</td>
<td className="p-4 text-sm">{session.totalXp}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
};
export default SessionStats;

View File

@@ -9,6 +9,7 @@ import { StatsOverview } from './sections/StatsOverview';
import { ClearModal } from './ClearModal'; import { ClearModal } from './ClearModal';
import Achievements from './sections/Achievements'; import Achievements from './sections/Achievements';
import StatsDashboard from './sections/StatsDashboard'; import StatsDashboard from './sections/StatsDashboard';
import SessionStats from '../components/SessionStats';
import { saveFile } from 'utils/saveFile'; import { saveFile } from 'utils/saveFile';
import variables from 'config/variables'; import variables from 'config/variables';
@@ -52,6 +53,7 @@ const Stats = () => {
setClearmodal(false); setClearmodal(false);
toast(variables.getMessage('toasts.stats_reset')); toast(variables.getMessage('toasts.stats_reset'));
updateAchievements(); // Call updateAchievements to refresh achievements after reset updateAchievements(); // Call updateAchievements to refresh achievements after reset
Stats.clearSessionStats(); // Clear session stats when resetting
}; };
const downloadStats = () => { const downloadStats = () => {
@@ -99,6 +101,9 @@ const Stats = () => {
variables={variables} variables={variables}
/> />
<StatsDashboard stats={stats} variables={variables} STATS_SECTION={STATS_SECTION} /> <StatsDashboard stats={stats} variables={variables} STATS_SECTION={STATS_SECTION} />
<div className="stats-section">
<SessionStats />
</div>
</> </>
); );
}; };

View File

@@ -1,7 +1,8 @@
const DB_NAME = 'StatsDB'; const DB_NAME = 'StatsDB';
const DB_VERSION = 2; // Incremented version const DB_VERSION = 4; // Increment version for new store
const STORE_NAME = 'eventLog'; const STORE_NAME = 'eventLog';
const CUSTOM_STORE_NAME = 'customImages'; const CUSTOM_STORE_NAME = 'customImages';
const SESSION_STORE_NAME = 'sessionStats';
export const openDB = () => { export const openDB = () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -10,11 +11,22 @@ export const openDB = () => {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = event.target.result; const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
// Add tabId to the schema
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
} }
if (!db.objectStoreNames.contains(CUSTOM_STORE_NAME)) { if (!db.objectStoreNames.contains(CUSTOM_STORE_NAME)) {
db.createObjectStore(CUSTOM_STORE_NAME, { keyPath: 'id', autoIncrement: true }); 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) => { 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);
});
});
};