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);
}
// 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');
}, []);

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 { 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)
);
});
}

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 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}
/>
<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_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);
});
});
};