mirror of
https://github.com/mue/mue.git
synced 2026-07-17 05:54:14 +02:00
feat(stats): local sessions
This commit is contained in:
@@ -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');
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
139
src/features/stats/components/SessionStats.jsx
Normal file
139
src/features/stats/components/SessionStats.jsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user