cloze-reader / src /leaderboardService.js
milwright
add leaderboard persistent storage and fix styling
af86b7d
raw
history blame
11.7 kB
/**
* Leaderboard Service
* Manages high scores, player stats, with HF Hub persistence and localStorage fallback
* Following arcade conventions with 3-letter initials and top 10 tracking
*/
import { HFLeaderboardAPI } from './hfLeaderboardAPI.js';
export class LeaderboardService {
constructor() {
this.storageKeys = {
leaderboard: 'cloze-reader-leaderboard',
player: 'cloze-reader-player',
stats: 'cloze-reader-stats'
};
this.maxEntries = 10;
// Initialize HF API client
this.hfAPI = new HFLeaderboardAPI();
this.useHF = false; // Will be set based on availability check
// Check HF availability and initialize
this.initializeAsync();
}
/**
* Async initialization to check HF availability
*/
async initializeAsync() {
try {
this.useHF = await this.hfAPI.isAvailable();
console.log(`πŸ”§ LEADERBOARD: Using ${this.useHF ? 'HF Hub backend' : 'localStorage only'}`);
} catch (error) {
console.warn('⚠️ LEADERBOARD: HF backend unavailable, using localStorage', error);
this.useHF = false;
}
// Reset all data on initialization (fresh start each session)
this.resetAll();
this.initializeStorage();
// If HF is available, sync from HF to localStorage
if (this.useHF) {
await this.syncFromHF();
}
}
/**
* Sync leaderboard from HF Hub to localStorage
*/
async syncFromHF() {
try {
const hfLeaderboard = await this.hfAPI.getLeaderboard();
if (hfLeaderboard && hfLeaderboard.length > 0) {
this.saveLeaderboard(hfLeaderboard);
console.log('πŸ“₯ LEADERBOARD: Synced from HF Hub', { entries: hfLeaderboard.length });
}
} catch (error) {
console.error('❌ LEADERBOARD: Failed to sync from HF', error);
}
}
/**
* Initialize localStorage with default values if needed
*/
initializeStorage() {
if (!this.getLeaderboard()) {
this.saveLeaderboard([]);
}
if (!this.getPlayerProfile()) {
this.savePlayerProfile({
initials: null,
hasEnteredInitials: false,
gamesPlayed: 0,
lastPlayed: null
});
}
if (!this.getStats()) {
this.saveStats(this.createEmptyStats());
}
}
/**
* Create empty stats object
*/
createEmptyStats() {
return {
highestLevel: 1,
roundAtHighestLevel: 1,
totalPassagesPassed: 0,
totalPassagesAttempted: 0,
longestStreak: 0,
currentStreak: 0,
totalCorrectWords: 0,
uniqueWordsCorrect: new Set(),
gamesPlayed: 0,
lastPlayed: null
};
}
/**
* Get leaderboard from localStorage
*/
getLeaderboard() {
try {
const data = localStorage.getItem(this.storageKeys.leaderboard);
return data ? JSON.parse(data) : null;
} catch (e) {
console.error('Error reading leaderboard:', e);
return [];
}
}
/**
* Save leaderboard to localStorage
*/
saveLeaderboard(entries) {
try {
localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries));
} catch (e) {
console.error('Error saving leaderboard:', e);
}
}
/**
* Get player profile from localStorage
*/
getPlayerProfile() {
try {
const data = localStorage.getItem(this.storageKeys.player);
return data ? JSON.parse(data) : null;
} catch (e) {
console.error('Error reading player profile:', e);
return null;
}
}
/**
* Save player profile to localStorage
*/
savePlayerProfile(profile) {
try {
localStorage.setItem(this.storageKeys.player, JSON.stringify(profile));
} catch (e) {
console.error('Error saving player profile:', e);
}
}
/**
* Get stats from localStorage
*/
getStats() {
try {
const data = localStorage.getItem(this.storageKeys.stats);
if (!data) return null;
const stats = JSON.parse(data);
// Convert uniqueWordsCorrect back to Set
if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) {
stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect);
} else {
stats.uniqueWordsCorrect = new Set();
}
return stats;
} catch (e) {
console.error('Error reading stats:', e);
return null;
}
}
/**
* Save stats to localStorage
*/
saveStats(stats) {
try {
// Convert Set to Array for JSON serialization
const statsToSave = {
...stats,
uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || [])
};
localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave));
} catch (e) {
console.error('Error saving stats:', e);
}
}
/**
* Validate and sanitize initials (3 letters, A-Z only)
*/
validateInitials(initials) {
if (!initials || typeof initials !== 'string') {
return false;
}
const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, '');
return sanitized.length === 3 ? sanitized : false;
}
/**
* Sort leaderboard entries
* Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc)
*/
sortLeaderboard(entries) {
return entries.sort((a, b) => {
// Primary: Level (higher is better)
if (b.level !== a.level) {
return b.level - a.level;
}
// Secondary: Round at that level (higher is better)
if (b.round !== a.round) {
return b.round - a.round;
}
// Tertiary: Total passages passed (higher is better)
if (b.passagesPassed !== a.passagesPassed) {
return b.passagesPassed - a.passagesPassed;
}
// Quaternary: Date (newer is better)
return new Date(b.date) - new Date(a.date);
});
}
/**
* Check if a score qualifies for the leaderboard
*/
qualifiesForLeaderboard(level, round, passagesPassed) {
const leaderboard = this.getLeaderboard();
// If leaderboard isn't full, always qualifies
if (leaderboard.length < this.maxEntries) {
return true;
}
// Check if better than lowest entry
const lowestEntry = leaderboard[leaderboard.length - 1];
if (level > lowestEntry.level) return true;
if (level === lowestEntry.level && round > lowestEntry.round) return true;
if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true;
return false;
}
/**
* Get the rank position for a score (1-10, or null if doesn't qualify)
*/
getRankForScore(level, round, passagesPassed) {
if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) {
return null;
}
const leaderboard = this.getLeaderboard();
const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() };
const tempLeaderboard = [...leaderboard, tempEntry];
const sorted = this.sortLeaderboard(tempLeaderboard);
return sorted.findIndex(entry => entry === tempEntry) + 1;
}
/**
* Add a new entry to the leaderboard
*/
async addEntry(initials, level, round, passagesPassed) {
const validInitials = this.validateInitials(initials);
if (!validInitials) {
console.error('Invalid initials:', initials);
return false;
}
const leaderboard = this.getLeaderboard();
const newEntry = {
initials: validInitials,
level,
round,
passagesPassed,
date: new Date().toISOString()
};
leaderboard.push(newEntry);
const sorted = this.sortLeaderboard(leaderboard);
// Keep only top 10
const trimmed = sorted.slice(0, this.maxEntries);
this.saveLeaderboard(trimmed);
const rank = sorted.findIndex(entry => entry === newEntry) + 1;
// If HF is available, also save to HF Hub
if (this.useHF) {
try {
await this.hfAPI.addEntry(newEntry);
console.log('πŸ“€ LEADERBOARD: Saved to HF Hub', { initials: validInitials, level, rank });
} catch (error) {
console.error('❌ LEADERBOARD: Failed to save to HF, localStorage only', error);
}
}
return rank; // Return rank
}
/**
* Update session stats after a passage attempt
*/
updateStats(data) {
const stats = this.getStats() || this.createEmptyStats();
console.log('πŸ“Š LEADERBOARD: Updating stats', {
before: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel },
passResult: data.passed,
currentLevel: data.currentLevel
});
stats.totalPassagesAttempted++;
if (data.passed) {
stats.totalPassagesPassed++;
stats.currentStreak++;
stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
} else {
stats.currentStreak = 0;
}
// Track highest level reached
if (data.currentLevel > stats.highestLevel) {
stats.highestLevel = data.currentLevel;
stats.roundAtHighestLevel = data.round;
} else if (data.currentLevel === stats.highestLevel) {
stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round);
}
// Track correct words
if (data.results) {
data.results.forEach(result => {
if (result.isCorrect) {
stats.totalCorrectWords++;
const word = result.correctAnswer.toLowerCase();
stats.uniqueWordsCorrect.add(word);
}
});
}
stats.lastPlayed = new Date().toISOString();
console.log('πŸ“Š LEADERBOARD: Stats updated', {
after: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel }
});
this.saveStats(stats);
return stats;
}
/**
* Get formatted leaderboard for display
*/
getFormattedLeaderboard() {
const leaderboard = this.getLeaderboard();
const player = this.getPlayerProfile();
const stats = this.getStats();
return {
entries: leaderboard.map((entry, index) => ({
rank: index + 1,
initials: entry.initials,
level: entry.level,
round: entry.round,
passagesPassed: entry.passagesPassed,
date: entry.date,
isPlayer: player && player.initials === entry.initials
})),
playerBest: stats ? {
level: stats.highestLevel,
round: stats.roundAtHighestLevel,
passagesPassed: stats.totalPassagesPassed
} : null,
playerInitials: player ? player.initials : null
};
}
/**
* Reset all leaderboard data (fresh start each session)
*/
resetAll() {
console.log('πŸ”„ LEADERBOARD: Starting fresh session (stats reset on page load)');
this.saveLeaderboard([]);
this.savePlayerProfile({
initials: null,
hasEnteredInitials: false,
gamesPlayed: 0,
lastPlayed: null
});
this.saveStats(this.createEmptyStats());
}
/**
* Get player stats summary
*/
getPlayerStats() {
const stats = this.getStats() || this.createEmptyStats();
const profile = this.getPlayerProfile();
return {
initials: profile?.initials || '---',
highestLevel: stats.highestLevel,
roundAtHighestLevel: stats.roundAtHighestLevel,
totalPassagesPassed: stats.totalPassagesPassed,
totalPassagesAttempted: stats.totalPassagesAttempted,
successRate: stats.totalPassagesAttempted > 0
? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100)
: 0,
longestStreak: stats.longestStreak,
currentStreak: stats.currentStreak,
totalCorrectWords: stats.totalCorrectWords,
uniqueWords: stats.uniqueWordsCorrect.size,
gamesPlayed: stats.gamesPlayed,
lastPlayed: stats.lastPlayed
};
}
}