mirror of
https://github.com/mue/mue.git
synced 2026-07-27 18:51:05 +02:00
feat(stats): local sessions
This commit is contained in:
@@ -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');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user