Race and racer data analytics: session tracking, hidden stats, XP breakdown, WPM curves, enhanced racelog, league calculators.
// ==UserScript==
// @name Nitro Type - Enhanced Stats
// @namespace https://nitrotype.info
// @version 2.1.2
// @description Race and racer data analytics: session tracking, hidden stats, XP breakdown, WPM curves, enhanced racelog, league calculators.
// @author Captain.Loveridge
// @match https://www.nitrotype.com/*
// @match *://*.nitrotype.com/settings/mods*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ─── Singleton Guard ─────────────────────────────────────────────────────────
const pageWindow = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const SCRIPT_SINGLETON_KEY = '__ntEnhancedStatsSingleton';
if (pageWindow[SCRIPT_SINGLETON_KEY]) {
try { console.info('[ESTATS] Duplicate instance detected; skipping.'); } catch (e) { }
return;
}
pageWindow[SCRIPT_SINGLETON_KEY] = true;
// ─── Constants ───────────────────────────────────────────────────────────────
const LOG_PREFIX = '[ESTATS]';
const NTCFG_MANIFEST_ID = 'enhanced-stats';
const NTCFG_MANIFEST_KEY = `ntcfg:manifest:${NTCFG_MANIFEST_ID}`;
const NTCFG_VALUE_PREFIX = `ntcfg:${NTCFG_MANIFEST_ID}:`;
const NTCFG_BRIDGE_VERSION = '1.0.0-bridge.1';
const SETTINGS_STORAGE_VERSION = 1;
const STORAGE_VERSION_KEY = `${NTCFG_VALUE_PREFIX}__storage_version`;
const STORAGE_MIGRATED_AT_KEY = `${NTCFG_VALUE_PREFIX}__migrated_at`;
const STORAGE_CLEANUP_AFTER_KEY = `${NTCFG_VALUE_PREFIX}__cleanup_after`;
const LEGACY_CLEANUP_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
// ─── Settings Definition ─────────────────────────────────────────────────────
const ESTATS_SETTINGS = {
// ── Racer Profile ────────────────────────────────────────────────────
ENABLE_SESSION_COUNTER: {
type: 'boolean',
label: 'Session Race Counter',
default: false,
group: 'Racer',
description: 'Show a persistent race counter next to your profile dropdown.'
},
SESSION_INACTIVITY_MINUTES: {
type: 'number',
label: 'Inactivity Reset (minutes)',
default: 30,
group: 'Racer',
min: 5,
max: 120,
step: 5,
description: 'Auto-reset session counter after this many minutes of inactivity.',
visibleWhen: { key: 'ENABLE_SESSION_COUNTER', eq: true }
},
ENABLE_HIDDEN_STATS: {
type: 'boolean',
label: 'Hidden Racer Stats',
default: true,
group: 'Racer',
description: 'Show hidden stats (Nitros Used, Cars Owned, Longest Session) on racer profiles.'
},
// ── Race ─────────────────────────────────────────────────────────────
ENABLE_RACE_ENHANCEMENTS: {
type: 'boolean',
label: 'Race Result Enhancements',
default: true,
group: 'Race',
description: 'Show season points and skipped characters on the post-race scoreboard.'
},
ENABLE_WPM_CURVE: {
type: 'boolean',
label: 'Post-Race Graph',
default: true,
group: 'Race',
description: 'Show a Graph button on race results to view WPM, accuracy, and nitro usage over time.'
},
// ── Stats Page ──────────────────────────────────────────────────────
ENABLE_ENHANCED_STATS_PAGE: {
type: 'boolean',
label: 'Enhanced Stats Overview',
default: true,
group: 'Stats',
description: 'Inject additional statistics from your account data into the stats page overview boxes and summary table.'
},
// ── Racelog ──────────────────────────────────────────────────────────
ENABLE_ENHANCED_RACELOG: {
type: 'boolean',
label: 'Enhanced Racelog',
default: true,
group: 'Stats',
description: 'Add extra columns, corrected stats, and session time estimates to the racelog.'
},
// ── Leagues ──────────────────────────────────────────────────────────
ENABLE_LEAGUE_CALCULATOR: {
type: 'boolean',
label: 'XP-Races Calculator',
default: true,
group: 'Leagues',
description: 'Show how many races needed to beat each league player.'
},
ENABLE_TOURNAMENT_WINS: {
type: 'boolean',
label: 'Tournament Wins',
default: true,
group: 'Leagues',
description: 'Show personal and team tournament win counts on the leagues page.'
},
// ── Garage ─────────────────────────────────────────────────────────
ENABLE_GARAGE_CAR_COUNT: {
type: 'boolean',
label: 'Garage Car Count',
default: true,
group: 'Racer',
description: 'Show total car count next to the "Cars" heading on the garage page.'
}
};
// ─── Settings Read/Write Utilities ────────────────────────────────────────────
const getStorageKey = (settingKey) => `${NTCFG_VALUE_PREFIX}${settingKey}`;
const canUseGMStorage = () => typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const readCanonicalValue = (storageKey) => {
if (!canUseGMStorage()) return undefined;
try {
return GM_getValue(storageKey);
} catch {
return undefined;
}
};
const writeCanonicalValue = (storageKey, value) => {
if (!canUseGMStorage()) return;
try {
GM_setValue(storageKey, value);
} catch { /* ignore */ }
};
const readStorageMetaNumber = (storageKey, fallback = 0) => {
const raw = readCanonicalValue(storageKey);
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : fallback;
};
const dispatchActionResult = (requestId, status, error = '') => {
if (!requestId) return;
try {
document.dispatchEvent(new CustomEvent('ntcfg:action-result', {
detail: {
requestId,
script: NTCFG_MANIFEST_ID,
status,
error
}
}));
} catch { /* ignore */ }
};
const readSetting = (settingKey) => {
const meta = ESTATS_SETTINGS[settingKey];
if (!meta) return undefined;
const storageKey = getStorageKey(settingKey);
try {
const canonical = readCanonicalValue(storageKey);
if (canonical !== undefined) {
return meta.type === 'boolean' ? !!canonical : canonical;
}
const raw = localStorage.getItem(storageKey);
if (raw == null) return meta.default;
const parsed = JSON.parse(raw);
if (meta.type === 'boolean') return !!parsed;
return parsed;
} catch {
return meta.default;
}
};
const writeSetting = (settingKey, value) => {
const meta = ESTATS_SETTINGS[settingKey];
if (!meta) return;
const normalized = meta.type === 'boolean' ? !!value : value;
const storageKey = getStorageKey(settingKey);
try {
writeCanonicalValue(storageKey, normalized);
const serialized = JSON.stringify(normalized);
if (localStorage.getItem(storageKey) !== serialized) {
localStorage.setItem(storageKey, serialized);
}
} catch { /* ignore storage failures */ }
};
const applySetting = (settingKey, value) => {
const meta = ESTATS_SETTINGS[settingKey];
if (!meta) return;
writeSetting(settingKey, meta.type === 'boolean' ? !!value : value);
applySettingSideEffects(settingKey);
};
const isFeatureEnabled = (settingKey) => readSetting(settingKey) !== false;
const applySettingSideEffects = (settingKey) => {
switch (settingKey) {
case 'ENABLE_SESSION_COUNTER':
handleSessionCounter();
break;
case 'ENABLE_HIDDEN_STATS':
cleanupHiddenStats();
if (readSetting(settingKey)) handleHiddenStats();
break;
case 'ENABLE_ENHANCED_STATS_PAGE':
cleanupEnhancedStatsPage();
if (readSetting(settingKey)) void handleEnhancedStatsPage();
break;
case 'ENABLE_ENHANCED_RACELOG':
cleanupEnhancedRacelog();
if (readSetting(settingKey)) void handleEnhancedRacelog();
break;
case 'ENABLE_LEAGUE_CALCULATOR':
cleanupLeagueCalculator();
if (readSetting(settingKey)) handleLeagueCalculator();
break;
case 'ENABLE_TOURNAMENT_WINS':
cleanupTournamentWins();
if (readSetting(settingKey)) void handleTournamentWins();
break;
case 'ENABLE_GARAGE_CAR_COUNT':
cleanupGarageCarCount();
if (readSetting(settingKey)) handleGarageCarCount();
break;
default:
break;
}
};
const applyAllLiveSettingSideEffects = () => {
handleSessionCounter();
cleanupHiddenStats();
if (isFeatureEnabled('ENABLE_HIDDEN_STATS')) handleHiddenStats();
cleanupEnhancedStatsPage();
if (isFeatureEnabled('ENABLE_ENHANCED_STATS_PAGE')) void handleEnhancedStatsPage();
cleanupEnhancedRacelog();
if (isFeatureEnabled('ENABLE_ENHANCED_RACELOG')) void handleEnhancedRacelog();
cleanupLeagueCalculator();
if (isFeatureEnabled('ENABLE_LEAGUE_CALCULATOR')) handleLeagueCalculator();
cleanupTournamentWins();
if (isFeatureEnabled('ENABLE_TOURNAMENT_WINS')) void handleTournamentWins();
cleanupGarageCarCount();
if (isFeatureEnabled('ENABLE_GARAGE_CAR_COUNT')) handleGarageCarCount();
};
// ─── Manifest Registration ────────────────────────────────────────────────────
const registerManifest = () => {
try {
const manifest = {
id: NTCFG_MANIFEST_ID,
name: 'Stats',
version: NTCFG_BRIDGE_VERSION,
scriptVersion: typeof GM_info !== 'undefined' ? GM_info.script.version : '',
storageVersion: SETTINGS_STORAGE_VERSION,
supportsGlobalReset: true,
description: 'Race and racer data analytics: session tracking, hidden stats, XP breakdown, WPM curves, enhanced racelog, league calculators.',
icon: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>',
sections: [
{ id: 'racer', title: 'Racer Profile', subtitle: 'Session tracking, hidden stats, and garage info.', resetButton: 'Reset Racer Profile to Defaults' },
{ id: 'race', title: 'Race', subtitle: 'In-race analytics and post-race breakdowns.', resetButton: 'Reset Race to Defaults' },
{ id: 'stats', title: 'Stats Page', subtitle: 'Enhanced overview, racelog, and hidden account data.', resetButton: 'Reset Stats Page to Defaults' },
{ id: 'leagues', title: 'Leagues', subtitle: 'League calculators and tournament stats.', resetButton: 'Reset Leagues to Defaults' }
],
settings: ESTATS_SETTINGS
};
const serialized = JSON.stringify(manifest);
if (localStorage.getItem(NTCFG_MANIFEST_KEY) !== serialized) {
localStorage.setItem(NTCFG_MANIFEST_KEY, serialized);
}
} catch { /* ignore */ }
};
const syncAllSettings = () => {
Object.keys(ESTATS_SETTINGS).forEach((key) => {
writeSetting(key, readSetting(key));
});
};
const ensureEnhancedStatsStorageMigration = () => {
const currentVersion = readStorageMetaNumber(STORAGE_VERSION_KEY, 0);
const migratedAt = readStorageMetaNumber(STORAGE_MIGRATED_AT_KEY, 0);
const now = Date.now();
if (currentVersion < SETTINGS_STORAGE_VERSION) {
syncAllSettings();
writeCanonicalValue(STORAGE_VERSION_KEY, SETTINGS_STORAGE_VERSION);
if (!migratedAt) {
writeCanonicalValue(STORAGE_MIGRATED_AT_KEY, now);
writeCanonicalValue(STORAGE_CLEANUP_AFTER_KEY, now + LEGACY_CLEANUP_GRACE_MS);
}
}
};
const resetEnhancedStatsSettingsToDefaults = () => {
Object.entries(ESTATS_SETTINGS).forEach(([settingKey, meta]) => {
if (meta.type === 'note' || meta.type === 'action') return;
writeSetting(settingKey, meta.default);
});
};
// Listen for mod menu changes (same tab)
document.addEventListener('ntcfg:change', (event) => {
if (event?.detail?.script !== NTCFG_MANIFEST_ID) return;
applySetting(event.detail.key, event.detail.value);
});
document.addEventListener('ntcfg:action', (event) => {
const detail = event?.detail || {};
if (detail.script !== '*') return;
if (detail.key !== 'clear-settings' || detail.scope !== 'prefs+caches') return;
try {
resetEnhancedStatsSettingsToDefaults();
writeCanonicalValue(STORAGE_VERSION_KEY, SETTINGS_STORAGE_VERSION);
writeCanonicalValue(STORAGE_MIGRATED_AT_KEY, Date.now());
writeCanonicalValue(STORAGE_CLEANUP_AFTER_KEY, Date.now() + LEGACY_CLEANUP_GRACE_MS);
registerManifest();
syncAllSettings();
applyAllLiveSettingSideEffects();
document.dispatchEvent(new CustomEvent('ntcfg:manifest-updated', {
detail: { script: NTCFG_MANIFEST_ID }
}));
dispatchActionResult(detail.requestId, 'success');
} catch (error) {
dispatchActionResult(detail.requestId, 'error', error?.message || String(error));
}
});
// Listen for cross-tab changes
window.addEventListener('storage', (event) => {
const key = String(event?.key || '');
if (!key.startsWith(NTCFG_VALUE_PREFIX) || event.newValue == null) return;
const settingKey = key.slice(NTCFG_VALUE_PREFIX.length);
if (!ESTATS_SETTINGS[settingKey]) return;
try { applySetting(settingKey, JSON.parse(event.newValue)); } catch { /* ignore */ }
});
ensureEnhancedStatsStorageMigration();
registerManifest();
syncAllSettings();
// Alive signal — write BEFORE dispatching manifest-updated so the mod menu sees this script as alive when it re-renders.
try { localStorage.setItem('ntcfg:alive:' + NTCFG_MANIFEST_ID, String(Date.now())); } catch { /* ignore */ }
try {
document.dispatchEvent(new CustomEvent('ntcfg:manifest-updated', {
detail: { script: NTCFG_MANIFEST_ID }
}));
} catch { /* ignore */ }
// ─── Shared Utilities ─────────────────────────────────────────────────────────
/** Traverse React fiber tree to find component instance. */
const findReact = (dom, traverseUp = 0) => {
if (!dom) return null;
const key = Object.keys(dom).find((k) => k.startsWith('__reactFiber$'));
const domFiber = dom[key];
if (domFiber == null) return null;
const getCompFiber = (fiber) => {
let parentFiber = fiber?.return;
while (typeof parentFiber?.type === 'string') {
parentFiber = parentFiber?.return;
}
return parentFiber;
};
let compFiber = getCompFiber(domFiber);
for (let i = 0; i < traverseUp && compFiber; i++) {
compFiber = getCompFiber(compFiber);
}
return compFiber?.stateNode;
};
/** Get React fiber from a DOM node. */
const getReactFiber = (dom) => {
if (!dom) return null;
const key = Object.keys(dom).find((k) =>
k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')
);
return key ? dom[key] : null;
};
/** Walk up fiber tree looking for props containing a given key. */
const findReactProp = (dom, propName, maxSteps = 30) => {
let fiber = getReactFiber(dom);
let steps = 0;
while (fiber && steps++ < maxSteps) {
const props = fiber.memoizedProps || fiber.pendingProps || null;
if (props && propName in props) return props[propName];
const stateNode = fiber.stateNode;
if (stateNode?.props && propName in stateNode.props) return stateNode.props[propName];
fiber = fiber.return;
}
return undefined;
};
/** Escape HTML entities for safe injection. */
const escapeHtml = (value) => {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
/** Normalize a pathname by stripping trailing slashes. */
const normalizePath = (pathname) => {
if (!pathname || pathname === '/') return '/';
return pathname.replace(/\/+$/, '') || '/';
};
/** Get current user data from persist:nt localStorage. */
const getCurrentUser = () => {
try {
const persist = JSON.parse(localStorage.getItem('persist:nt'));
return JSON.parse(persist.user);
} catch {
return null;
}
};
/** Get player auth token for API calls. */
const getAuthToken = () => {
return localStorage.getItem('player_token');
};
/** Authenticated fetch helper. */
const apiFetch = async (endpoint) => {
const token = getAuthToken();
if (!token) throw new Error('No auth token');
const res = await fetch(endpoint, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`API ${res.status}`);
return res.json();
};
// ─── SPA Navigation Hooks ─────────────────────────────────────────────────────
const originalPushState = history.pushState;
history.pushState = function () {
const result = originalPushState.apply(this, arguments);
onRouteChange();
return result;
};
const originalReplaceState = history.replaceState;
history.replaceState = function () {
const result = originalReplaceState.apply(this, arguments);
onRouteChange();
return result;
};
window.addEventListener('popstate', onRouteChange);
// ─── Race Page Cleanup Registry ───────────────────────────────────────────────
const cleanupFns = [];
const registerCleanup = (fn) => cleanupFns.push(fn);
const runCleanup = () => {
while (cleanupFns.length) {
try { cleanupFns.pop()(); } catch (e) {
console.error(LOG_PREFIX, 'Cleanup error:', e);
}
}
};
const rememberOriginalStyle = (element) => {
if (!element || element.hasAttribute('data-estats-original-style')) return;
const original = element.getAttribute('style');
element.setAttribute('data-estats-original-style', original == null ? '__none__' : original);
};
const restoreOriginalStyle = (element) => {
if (!element || !element.hasAttribute('data-estats-original-style')) return;
const original = element.getAttribute('data-estats-original-style');
if (original === '__none__') {
element.removeAttribute('style');
} else {
element.setAttribute('style', original);
}
element.removeAttribute('data-estats-original-style');
};
const rememberOriginalText = (element) => {
if (!element || element.hasAttribute('data-estats-original-text')) return;
element.setAttribute('data-estats-original-text', element.textContent ?? '');
};
const restoreOriginalText = (element) => {
if (!element || !element.hasAttribute('data-estats-original-text')) return;
element.textContent = element.getAttribute('data-estats-original-text') || '';
element.removeAttribute('data-estats-original-text');
};
const rememberOriginalHtml = (element, attrName = 'data-estats-original-html') => {
if (!element || element.hasAttribute(attrName)) return;
element.setAttribute(attrName, element.innerHTML);
};
const restoreOriginalHtml = (element, attrName = 'data-estats-original-html') => {
if (!element || !element.hasAttribute(attrName)) return;
element.innerHTML = element.getAttribute(attrName) || '';
element.removeAttribute(attrName);
};
function onRouteChange() {
runCleanup();
raceHooked = false;
wpmSamples = {};
}
// ─── Observer Manager Integration ─────────────────────────────────────────────
function initObserverManager() {
const existing = window.NTObserverManager || {};
if (existing.version !== '1.0.0' && existing.observer && typeof existing.observer.disconnect === 'function') {
try { existing.observer.disconnect(); } catch (e) { }
existing.observer = null;
}
if (existing.debounceTimer) {
clearTimeout(existing.debounceTimer);
existing.debounceTimer = null;
}
existing.callbacks = existing.callbacks || {};
existing.version = '1.0.0';
existing.register = function (scriptName, callback) {
this.callbacks[scriptName] = callback;
if (!this.observer) {
this.observer = new MutationObserver(() => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
Object.values(this.callbacks).forEach((cb) => {
try { cb(); } catch (e) { console.error('[Observer Error]', e); }
});
}, 250);
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
try {
callback();
} catch (e) {
console.error('[Observer Error]', e);
}
};
window.NTObserverManager = existing;
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 1: Session Race Counter (Global)
// ─────────────────────────────────────────────────────────────────────────────
const SESSION_COUNTER_ATTR = 'data-estats-session-counter';
const SESSION_STORAGE_KEY = 'estats:session';
function getSessionData() {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
}
function setSessionData(data) {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(data));
} catch { /* ignore */ }
}
function handleSessionCounter() {
if (!isFeatureEnabled('ENABLE_SESSION_COUNTER')) {
// Remove counter if feature was disabled
const existing = document.querySelector(`[${SESSION_COUNTER_ATTR}]`);
if (existing) existing.remove();
return;
}
const user = getCurrentUser();
if (!user) return;
const ntSessionRaces = user.sessionRaces || 0;
const inactivityMs = (readSetting('SESSION_INACTIVITY_MINUTES') || 30) * 60 * 1000;
const now = Date.now();
let session = getSessionData();
if (!session) {
session = { count: ntSessionRaces, lastNtCount: ntSessionRaces, lastActive: now };
}
// Reset if NT's session dropped (NT reset its session)
if (ntSessionRaces < session.lastNtCount) {
session = { count: ntSessionRaces, lastNtCount: ntSessionRaces, lastActive: now };
}
// Reset if inactivity timeout exceeded
if (now - session.lastActive > inactivityMs) {
session = { count: ntSessionRaces, lastNtCount: ntSessionRaces, lastActive: now };
}
// Detect new races: if NT count increased since our last check
const delta = ntSessionRaces - session.lastNtCount;
if (delta > 0) {
session.count += delta;
session.lastNtCount = ntSessionRaces;
session.lastActive = now;
}
setSessionData(session);
// Inject counter into the profile dropdown trigger area
const dropdownTrigger = document.querySelector('.dropdown-trigger');
if (!dropdownTrigger) return;
let counter = dropdownTrigger.querySelector(`[${SESSION_COUNTER_ATTR}]`);
if (!counter) {
counter = document.createElement('span');
counter.setAttribute(SESSION_COUNTER_ATTR, '');
counter.style.cssText = 'display:block;font-size:11px;font-weight:600;color:#a6aac1;margin-top:4px;';
dropdownTrigger.appendChild(counter);
}
counter.textContent = 'Current Session: ' + session.count + (session.count === 1 ? ' Race' : ' Races');
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 2: Hidden Racer Stats (/racer/*)
// ─────────────────────────────────────────────────────────────────────────────
const HIDDEN_STATS_ATTR = 'data-estats-hidden-stats';
function cleanupHiddenStats() {
const levelContainer = document.querySelector('.profile--grid--level');
if (levelContainer && levelContainer.hasAttribute(HIDDEN_STATS_ATTR)) {
restoreOriginalHtml(levelContainer, 'data-estats-hidden-level-html');
restoreOriginalStyle(levelContainer);
levelContainer.removeAttribute(HIDDEN_STATS_ATTR);
}
const playerStats = document.querySelector('.profile-playerStats');
if (playerStats && playerStats.hasAttribute(HIDDEN_STATS_ATTR)) {
restoreOriginalHtml(playerStats, 'data-estats-hidden-player-html');
restoreOriginalStyle(playerStats);
playerStats.removeAttribute(HIDDEN_STATS_ATTR);
}
document.querySelectorAll('[data-estats-carcount]').forEach((el) => el.remove());
}
/** Format seconds into a human-readable duration. */
const formatDuration = (totalSeconds) => {
if (!totalSeconds || totalSeconds <= 0) return '—';
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
/** Format a unix timestamp to a readable date. */
const formatTimestamp = (ts) => {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
};
/** Build a stat well HTML block. */
const statWell = (label, value, color = '#d8dcf2') => `
<div class="well tac" style="padding:10px 8px;">
<div class="tsxs ttu tc-ts" style="margin-bottom:4px;">${escapeHtml(label)}</div>
<div class="tss" style="font-weight:700;color:${color};">${escapeHtml(String(value))}</div>
</div>`;
/** Build a section header. */
const sectionHeader = (title) =>
`<h5 class="tsxs ttu" style="margin:16px 0 8px;color:#99a4c5;letter-spacing:0.5px;">${escapeHtml(title)}</h5>`;
/**
* Match Nitro's season infinity-tier logic:
* infinity starts after the final finite reward level (totalRewards + 1).
*/
function formatSeasonLevel(level) {
if (level == null || level <= 0) return null;
try {
const season = pageWindow.NTGLOBALS?.ACTIVE_SEASONS?.[0];
const totalRewards = Number(season?.totalRewards);
if (Number.isFinite(totalRewards) && level > (totalRewards + 1)) {
return `∞${level - totalRewards - 1}`;
}
} catch { /* ignore */ }
return String(level);
}
function getPlayerXpThreshold(level) {
if (!Number.isFinite(level) || level <= 0) return 0;
const plCfg = pageWindow.NTGLOBALS?.PLAYER_LEVELS;
const plBase = (plCfg && Number.isFinite(plCfg.basePoints)) ? plCfg.basePoints : 500;
const plMult = (plCfg && Number.isFinite(plCfg.multiple)) ? plCfg.multiple : 2;
return Math.floor((plBase * plMult * Math.pow(level, 2)) + (plBase * level));
}
function handleHiddenStats() {
if (!isFeatureEnabled('ENABLE_HIDDEN_STATS')) return;
const path = normalizePath(window.location.pathname);
if (!path.startsWith('/racer/')) return;
if (document.querySelector(`[${HIDDEN_STATS_ATTR}]`)) return;
const info = pageWindow.NTGLOBALS?.RACER_INFO;
if (!info || !info.username) return;
// Find the stats section on the profile page
const profileGrid = document.querySelector('.profile--grid--level');
if (!profileGrid) return;
const statsParent = profileGrid.closest('.card') || profileGrid.closest('section') || profileGrid.parentElement;
if (!statsParent) return;
// ── Inject Season Level into .profile--grid--level ──
const levelContainer = document.querySelector('.profile--grid--level');
if (levelContainer && !levelContainer.querySelector('[data-estats-season-level]')) {
const seasonLevel = info.level;
const seasonLevelStr = formatSeasonLevel(seasonLevel);
if (seasonLevelStr) {
rememberOriginalHtml(levelContainer, 'data-estats-hidden-level-html');
rememberOriginalStyle(levelContainer);
levelContainer.setAttribute(HIDDEN_STATS_ATTR, '');
levelContainer.style.cssText = 'display:flex;align-items:center;justify-content:flex-end;padding:12px 16px;';
const levelInner = document.createElement('div');
levelInner.setAttribute('data-estats-season-level', '');
levelInner.style.textAlign = 'right';
levelInner.innerHTML = `
<div class="tsxl twb" style="line-height:1;"><span class="tc-i">LVL</span> <span class="tc-fuel">${seasonLevelStr}</span></div>
`;
levelContainer.appendChild(levelInner);
}
}
// ── Inject extra stats into .profile-playerStats ──
const playerStats = document.querySelector('.profile-playerStats');
if (playerStats && !playerStats.querySelector('[data-estats-inline]')) {
const hasExtra = info.longestSession != null || info.experience != null || info.nitrosUsed != null || info.nitros != null;
if (hasExtra) {
rememberOriginalHtml(playerStats, 'data-estats-hidden-player-html');
rememberOriginalStyle(playerStats);
playerStats.setAttribute(HIDDEN_STATS_ATTR, '');
playerStats.style.cssText = 'display:inline-grid;grid-template-columns:repeat(3,auto);gap:0 32px;';
// Wrap existing native stats in column 1
const nativeCol = document.createElement('div');
nativeCol.setAttribute('data-estats-inline', '');
while (playerStats.firstChild) {
nativeCol.appendChild(playerStats.firstChild);
}
// Force native rows to simple flex layout (split--inline spreads them)
nativeCol.querySelectorAll('.split').forEach(el => {
el.style.cssText = 'display:flex !important;align-items:baseline;gap:6px;';
el.className = '';
});
nativeCol.querySelectorAll('.split-cell').forEach(el => {
el.style.cssText = 'flex:none;';
el.className = '';
});
playerStats.appendChild(nativeCol);
const makeRow = (label, value) => `<div style="display:flex;align-items:baseline;gap:6px;"><div class="tsxxs ttu">${label}</div><div class="tss">${value}</div></div>`;
// Column 2: Longest Session + Season XP
const col2 = document.createElement('div');
if (info.longestSession != null) col2.innerHTML += makeRow('Longest Session', info.longestSession.toLocaleString() + ' races');
if (info.experience != null) col2.innerHTML += makeRow('Season XP', info.experience.toLocaleString());
if (col2.children.length) playerStats.appendChild(col2);
// Column 3: Nitros Used + Nitros Owned
const col3 = document.createElement('div');
if (info.nitrosUsed != null) col3.innerHTML += makeRow('Nitros Used', info.nitrosUsed.toLocaleString());
if (info.nitros != null) col3.innerHTML += makeRow('Nitros Owned', info.nitros.toLocaleString());
if (col3.children.length) playerStats.appendChild(col3);
}
}
// ── Inject total cars count next to "Cars" heading ──
const carsHeading = document.querySelector('.card-cap h1');
if (carsHeading && carsHeading.textContent.trim() === 'Cars' && !carsHeading.querySelector('[data-estats-carcount]')) {
const totalCars = info.cars ? info.cars.length :
(info.garage ? new Set(info.garage.filter(id => id && id !== '')).size : 0);
if (totalCars) {
const countSpan = document.createElement('span');
countSpan.setAttribute('data-estats-carcount', '');
countSpan.className = 'tbs';
countSpan.style.cssText = 'margin-left:8px;';
countSpan.textContent = `| ${totalCars}`;
carsHeading.appendChild(countSpan);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 3: Race Result Enhancements (/race)
// ─────────────────────────────────────────────────────────────────────────────
const RACE_ENHANCE_ATTR = 'data-estats-race-enhanced';
let raceHooked = false;
function applyRaceResultStatsListLayout(cell, statsList) {
if (!cell || !statsList) return;
const splitReverse = cell.querySelector('.split.split--reverse');
if (!splitReverse) return;
const nameWrapper = cell.querySelector('.raceResults-playerName');
const titleCell = splitReverse.querySelector('.split-cell > .tsxs.tc-fuel');
if (titleCell && nameWrapper && titleCell.parentElement !== nameWrapper) {
titleCell.style.cssText = 'margin-top:2px;';
nameWrapper.appendChild(titleCell);
}
splitReverse.style.cssText = 'display:block;';
const statsCell = statsList.closest('.split-cell');
if (statsCell) {
statsCell.style.cssText = 'width:100%;max-width:100%;';
}
statsList.style.cssText = 'flex-wrap:wrap;margin-top:2px;';
}
function handleRaceEnhancements() {
if (!isFeatureEnabled('ENABLE_RACE_ENHANCEMENTS')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/race' && !path.startsWith('/race/')) return;
const raceContainer = document.getElementById('raceContainer');
if (!raceContainer) return;
const results = raceContainer.querySelector('.race-results');
if (!results) return;
if (results.hasAttribute(RACE_ENHANCE_ATTR)) return;
const raceObj = findReact(raceContainer);
if (!raceObj) return;
const racers = raceObj.state?.racers;
const user = raceObj.props?.user;
if (!racers || !user) return;
results.setAttribute(RACE_ENHANCE_ATTR, '');
// Calculate season points for each racer
const racerStats = {};
racers.forEach(racer => {
const progress = racer.progress || {};
const typed = progress.typed || 0;
const skipped = progress.skipped || 0;
const errors = progress.errors || 0;
const startStamp = progress.startStamp || 0;
const completeStamp = progress.completeStamp || 0;
if (completeStamp <= 0 || startStamp <= 0) {
racerStats[racer.userID] = { points: 0, skipped, errors };
return;
}
const timeSeconds = (completeStamp - startStamp) / 1000;
const wpm = timeSeconds > 0 ? ((typed - skipped) / 5) / (timeSeconds / 60) : 0;
const totalTyped = typed - skipped;
const accuracy = totalTyped > 0 ? (totalTyped - errors) / totalTyped : 0;
const points = Math.max(0, Math.round(1 * accuracy * (100 + wpm / 2)));
racerStats[racer.userID] = { points, skipped, errors };
});
// Inject directly into each racer's existing result row
// NT structure: .gridTable-cell > .split.split--flag > .split-cell > .list.list--inline
const resultCells = results.querySelectorAll('.gridTable-cell');
resultCells.forEach(cell => {
// Find the player name to match with racer data
const nameContainer = cell.querySelector('.player-name--container');
if (!nameContainer) return;
// Find the existing stats list (WPM, Acc, secs)
const statsList = cell.querySelector('.list.list--inline.list--flag');
if (!statsList) return;
if (statsList.hasAttribute('data-estats-injected')) return;
statsList.setAttribute('data-estats-injected', '');
// Match this cell to a racer by display name
const displayName = nameContainer.getAttribute('title') || '';
const racer = racers.find(r => {
const rName = r.profile?.displayName || r.profile?.username || '';
return rName === displayName;
});
if (!racer || !racerStats[racer.userID]) return;
const stats = racerStats[racer.userID];
// Inject season points into the existing list
const ptsItem = document.createElement('div');
ptsItem.className = 'list-item';
ptsItem.innerHTML = `${stats.points} <span class="tc-ts">Points</span>`;
statsList.appendChild(ptsItem);
// Inject skipped count if > 0
if (stats.skipped > 0) {
const skipItem = document.createElement('div');
skipItem.className = 'list-item';
skipItem.innerHTML = `${stats.skipped} <span class="tc-ts">Skipped</span>`;
statsList.appendChild(skipItem);
}
// Inject error count if > 0
if (stats.errors > 0) {
const errItem = document.createElement('div');
errItem.className = 'list-item';
errItem.innerHTML = `${stats.errors} <span class="tc-ts">Errors</span>`;
statsList.appendChild(errItem);
}
applyRaceResultStatsListLayout(cell, statsList);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 4: Post-Race WPM Curve (/race)
// ─────────────────────────────────────────────────────────────────────────────
const WPM_CURVE_ATTR = 'data-estats-wpm-curve';
let wpmSamples = {}; // { userID: [{time, typed, skipped}] }
function getNearestCurvePoint(points, targetElapsed) {
if (!Array.isArray(points) || points.length === 0 || !Number.isFinite(targetElapsed)) return null;
let nearest = points[0];
let nearestDiff = Math.abs(points[0].elapsed - targetElapsed);
for (let i = 1; i < points.length; i++) {
const diff = Math.abs(points[i].elapsed - targetElapsed);
if (diff < nearestDiff) {
nearest = points[i];
nearestDiff = diff;
}
}
return nearest;
}
function hookRaceServer() {
if (raceHooked) return;
const path = normalizePath(window.location.pathname);
if (path !== '/race' && !path.startsWith('/race/')) return;
const raceContainer = document.getElementById('raceContainer');
if (!raceContainer) return;
const raceObj = findReact(raceContainer);
if (!raceObj?.server) return;
const server = raceObj.server;
const user = raceObj.props?.user;
if (!user) return;
raceHooked = true;
wpmSamples = {};
// Hook into update events for WPM sampling
const onUpdate = (e) => {
if (!e?.racers) return;
const now = Date.now();
e.racers.forEach(racer => {
if (!racer.userID) return;
if (!wpmSamples[racer.userID]) {
wpmSamples[racer.userID] = [];
}
wpmSamples[racer.userID].push({
time: now,
typed: racer.progress?.typed || 0,
skipped: racer.progress?.skipped || 0,
errors: racer.progress?.errors || 0,
complete: racer.progress?.completeStamp > 0
});
});
};
server.on('update', onUpdate);
// Register cleanup to remove listener on navigation
registerCleanup(() => {
try { server.off('update', onUpdate); } catch { /* ignore */ }
});
}
function handleWPMCurve() {
if (!isFeatureEnabled('ENABLE_WPM_CURVE')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/race' && !path.startsWith('/race/')) return;
// Try to hook the race server if not already done
hookRaceServer();
const raceContainer = document.getElementById('raceContainer');
if (!raceContainer) return;
const results = raceContainer.querySelector('.race-results');
if (!results) return;
if (document.querySelector(`[${WPM_CURVE_ATTR}]`)) return;
// Need at least some samples to draw
const sampleKeys = Object.keys(wpmSamples);
if (sampleKeys.length === 0) return;
// Check all racers have completed
const raceObj = findReact(raceContainer);
const user = raceObj?.props?.user;
// Build WPM data series for each racer
const series = [];
const MY_RACE_GRAPH_COLOR = '#f3a81b';
const OPPONENT_GRAPH_COLORS = ['#d62f3a', '#4ade80', '#1c99f4', '#a855f7', '#f97316', '#14b8a6', '#f43f5e', '#94a3b8'];
let opponentColorIndex = 0;
sampleKeys.forEach((uid, idx) => {
const samples = wpmSamples[uid];
if (samples.length < 2) return;
const startTime = samples[0].time;
const dataPoints = [];
const nitroMarkers = []; // timestamps when nitros were used
const accuracyPoints = []; // running accuracy over time
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1];
const curr = samples[i];
const dt = (curr.time - prev.time) / 1000;
if (dt <= 0) continue;
const charsDelta = (curr.typed - curr.skipped) - (prev.typed - prev.skipped);
if (charsDelta < 0) continue;
const wpm = (charsDelta / 5) / (dt / 60);
const elapsed = (curr.time - startTime) / 1000;
dataPoints.push({ elapsed, wpm: Math.round(wpm) });
// Detect nitro usage (skipped increased)
if (curr.skipped > prev.skipped) {
nitroMarkers.push(elapsed);
}
// Running accuracy: (typed - skipped - errors) / (typed - skipped)
const netTyped = curr.typed - curr.skipped;
if (netTyped > 0) {
const acc = Math.max(0, (netTyped - curr.errors) / netTyped) * 100;
accuracyPoints.push({ elapsed, accuracy: Math.round(acc * 100) / 100 });
}
}
if (dataPoints.length < 2) return;
// Smooth WPM with a moving average (window of 3)
const trendWindow = 3;
const smoothed = [];
for (let i = 0; i < dataPoints.length; i++) {
const start = Math.max(0, i - 1);
const end = Math.min(dataPoints.length - 1, i + 1);
let sum = 0;
let count = 0;
for (let j = start; j <= end; j++) {
sum += dataPoints[j].wpm;
count++;
}
smoothed.push({ elapsed: dataPoints[i].elapsed, wpm: Math.round(sum / count) });
}
const isMe = user && String(uid) === String(user.userID);
const racer = raceObj?.state?.racers?.find(r => String(r.userID) === String(uid));
const name = racer?.profile?.displayName || racer?.profile?.username || 'Racer';
const lastSample = samples[samples.length - 1];
const netTyped = lastSample.typed - lastSample.skipped;
const finalAcc = netTyped > 0 ? Math.round(((netTyped - lastSample.errors) / netTyped) * 10000) / 100 : 0;
const avgWpm = dataPoints.length > 0 ? Math.round(dataPoints.reduce((s, d) => s + d.wpm, 0) / dataPoints.length) : 0;
const color = isMe
? MY_RACE_GRAPH_COLOR
: OPPONENT_GRAPH_COLORS[(opponentColorIndex++) % OPPONENT_GRAPH_COLORS.length];
series.push({
uid,
name,
isMe,
data: smoothed,
rawData: dataPoints,
nitroMarkers,
accuracyPoints,
finalAcc,
avgWpm,
totalNitros: nitroMarkers.length,
trendWindow,
color
});
});
if (series.length === 0) return;
// Add graph button directly left of the minimize button
const minimizeBtn = results.querySelector('.raceResults-close.raceResults-close--minimizer');
if (!minimizeBtn || minimizeBtn.parentNode.querySelector('[data-estats-graph-btn]')) return;
// Make the parent a flex container so both buttons sit side by side
const btnParent = minimizeBtn.parentNode;
rememberOriginalStyle(btnParent);
btnParent.style.display = 'flex';
btnParent.style.alignItems = 'center';
btnParent.style.gap = '8px';
const graphBtn = document.createElement('button');
graphBtn.setAttribute('data-estats-graph-btn', '');
graphBtn.setAttribute(WPM_CURVE_ATTR, '');
graphBtn.className = 'raceResults-close raceResults-close--minimizer';
graphBtn.title = 'WPM Graph';
graphBtn.innerHTML = `<span style="display:flex;align-items:center;gap:4px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg><span style="color:#fff;font-size:11px;font-weight:700;letter-spacing:0.5px;">Graph</span></span>`;
// Match minimize button height, override absolute positioning
const minBtnHeight = minimizeBtn.offsetHeight || minimizeBtn.getBoundingClientRect().height;
graphBtn.style.position = 'relative';
graphBtn.style.height = `${minBtnHeight}px`;
graphBtn.style.display = 'flex';
graphBtn.style.alignItems = 'center';
graphBtn.style.justifyContent = 'center';
rememberOriginalStyle(minimizeBtn);
minimizeBtn.style.position = 'relative';
btnParent.insertBefore(graphBtn, minimizeBtn);
graphBtn.addEventListener('click', () => {
// Toggle state
const chartState = {
showSpeed: true,
showAccuracy: true,
showNitros: true,
hiddenRacers: new Set(),
hoverMouse: null
};
// Create full-screen modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#1d2030;border-radius:12px;padding:24px 32px;width:90vw;max-width:1200px;position:relative;border:1px solid rgba(255,255,255,0.1);';
// Close button
const closeBtn = document.createElement('button');
closeBtn.style.cssText = 'position:absolute;top:12px;right:16px;background:none;border:none;color:#a6aac1;font-size:24px;cursor:pointer;line-height:1;';
closeBtn.innerHTML = '×';
modal.appendChild(closeBtn);
// Title
const title = document.createElement('h4');
title.className = 'tss ttu tc-ts';
title.style.cssText = 'margin:0 0 16px;';
title.textContent = 'Race Performance Over Time';
modal.appendChild(title);
// Canvas
const canvasFrame = document.createElement('div');
canvasFrame.style.cssText = 'width:100%;max-width:100%;overflow:hidden;border-radius:8px;';
const canvas = document.createElement('canvas');
canvas.width = 1100;
canvas.height = 400;
canvas.style.cssText = 'display:block;width:100%;height:auto;max-width:100%;border-radius:8px;cursor:crosshair;';
canvasFrame.appendChild(canvas);
modal.appendChild(canvasFrame);
const redraw = () => drawWPMChart(canvas, series, chartState);
const syncHoverMouse = (event) => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
chartState.hoverMouse = {
x: ((event.clientX - rect.left) / rect.width) * (canvas._origW || canvas.width),
y: ((event.clientY - rect.top) / rect.height) * (canvas._origH || canvas.height)
};
redraw();
};
canvas.addEventListener('mousemove', syncHoverMouse);
canvas.addEventListener('mouseleave', () => {
chartState.hoverMouse = null;
redraw();
});
// Racer legend (clickable to toggle)
const legend = document.createElement('div');
legend.style.cssText = 'display:flex;flex-wrap:wrap;gap:10px;margin-top:16px;';
series.forEach(s => {
const item = document.createElement('div');
item.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:12px;color:#a6aac1;background:rgba(255,255,255,0.04);padding:6px 12px;border-radius:6px;cursor:pointer;user-select:none;transition:opacity 0.2s;';
item.innerHTML = `
<span class="estats-legend-dot" style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${s.color};flex-shrink:0;"></span>
<span style="font-weight:600;color:#fff;">${escapeHtml(s.name)}${s.isMe ? ' (You)' : ''}</span>
<span>${s.avgWpm} <span style="opacity:0.6;">avg WPM</span></span>
<span>${s.finalAcc}% <span style="opacity:0.6;">Acc</span></span>
${s.totalNitros > 0 ? `<span style="color:#1c99f4;">${s.totalNitros} <span style="opacity:0.7;">Nitros</span></span>` : ''}
`;
item.addEventListener('click', () => {
if (chartState.hiddenRacers.has(s.uid)) {
chartState.hiddenRacers.delete(s.uid);
item.style.opacity = '1';
item.querySelector('.estats-legend-dot').style.background = s.color;
} else {
chartState.hiddenRacers.add(s.uid);
item.style.opacity = '0.3';
item.querySelector('.estats-legend-dot').style.background = '#555';
}
redraw();
});
legend.appendChild(item);
});
modal.appendChild(legend);
// Filter toggle buttons
const filters = document.createElement('div');
filters.style.cssText = 'display:flex;gap:8px;margin-top:12px;';
const makeToggle = (label, color, key) => {
const btn = document.createElement('button');
btn.style.cssText = `background:${color};color:#fff;border:none;border-radius:4px;padding:5px 14px;font-size:11px;font-weight:700;cursor:pointer;letter-spacing:0.5px;text-transform:uppercase;opacity:1;transition:opacity 0.2s;`;
btn.textContent = label;
btn.addEventListener('click', () => {
chartState[key] = !chartState[key];
btn.style.opacity = chartState[key] ? '1' : '0.3';
redraw();
});
return btn;
};
filters.appendChild(makeToggle('Speed', '#f3a81b', 'showSpeed'));
filters.appendChild(makeToggle('Accuracy', '#4ade80', 'showAccuracy'));
filters.appendChild(makeToggle('Nitros', '#1c99f4', 'showNitros'));
modal.appendChild(filters);
// Chart key
const chartKey = document.createElement('div');
chartKey.style.cssText = 'display:flex;gap:20px;margin-top:10px;font-size:11px;color:rgba(255,255,255,0.4);align-items:center;';
chartKey.innerHTML = `
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:20px;height:2px;background:#fff;"></span> Solid = WPM Trend</span>
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:20px;height:0;border-top:2px dashed rgba(255,255,255,0.8);"></span> Dashed = Accuracy (same racer color)</span>
<span style="display:flex;align-items:center;gap:6px;"><span style="color:#1c99f4;">▼</span> <span style="display:inline-block;width:14px;height:0;border-top:1px dashed #1c99f4;"></span> = Nitro Used</span>
`;
modal.appendChild(chartKey);
overlay.appendChild(modal);
const closeOverlay = () => {
overlay.remove();
document.removeEventListener('keydown', onEsc);
};
// Close on overlay click (not modal)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeOverlay();
});
// Close on Escape key
const onEsc = (e) => { if (e.key === 'Escape') closeOverlay(); };
document.addEventListener('keydown', onEsc);
closeBtn.addEventListener('click', closeOverlay);
document.body.appendChild(overlay);
redraw();
});
}
function drawWPMChart(canvas, series, chartState = {}) {
const { showSpeed = true, showAccuracy = true, showNitros = true, hiddenRacers = new Set() } = chartState;
const visibleSeries = series.filter(s => !hiddenRacers.has(s.uid));
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
if (!canvas._baseW) {
canvas._baseW = canvas.width || 1100;
canvas._baseH = canvas.height || 400;
canvas._aspectRatio = canvas._baseW / canvas._baseH;
}
const parentWidth = canvas.parentElement?.clientWidth || canvas.getBoundingClientRect().width || canvas._baseW;
const w = Math.max(320, Math.round(Math.min(canvas._baseW, parentWidth)));
const h = Math.max(220, Math.round(w / (canvas._aspectRatio || (1100 / 400))));
canvas._origW = w;
canvas._origH = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const padding = { top: 20, right: showAccuracy ? 50 : 20, bottom: 30, left: 45 };
const chartW = w - padding.left - padding.right;
const chartH = h - padding.top - padding.bottom;
// Calculate data bounds from ALL series (keeps scale stable when toggling)
let maxTime = 0;
let maxWpm = 0;
let minWpm = Infinity;
let minAcc = Infinity;
let maxAcc = -Infinity;
series.forEach(s => {
s.data.forEach(d => {
if (d.elapsed > maxTime) maxTime = d.elapsed;
if (d.wpm > maxWpm) maxWpm = d.wpm;
if (d.wpm < minWpm) minWpm = d.wpm;
});
(s.accuracyPoints || []).forEach((d) => {
if (!Number.isFinite(d?.accuracy)) return;
minAcc = Math.min(minAcc, d.accuracy);
maxAcc = Math.max(maxAcc, d.accuracy);
});
});
if (!Number.isFinite(minWpm)) minWpm = 0;
if (!Number.isFinite(maxWpm) || maxWpm <= 0) maxWpm = 150;
minWpm = Math.max(0, Math.floor(minWpm / 10) * 10 - 10);
maxWpm = Math.max(minWpm + 10, Math.ceil(maxWpm / 10) * 10 + 10);
maxTime = Math.max(1, Math.ceil(maxTime));
if (!Number.isFinite(minAcc)) minAcc = 90;
if (!Number.isFinite(maxAcc) || maxAcc <= 0) maxAcc = 100;
minAcc = Math.max(0, Math.floor(minAcc) - 1);
maxAcc = Math.min(100, Math.ceil(maxAcc) + 1);
if (maxAcc - minAcc < 4) {
const midAcc = (maxAcc + minAcc) / 2;
minAcc = Math.max(0, Math.floor(midAcc - 2));
maxAcc = Math.min(100, Math.ceil(midAcc + 2));
}
if (maxAcc <= minAcc) maxAcc = Math.min(100, minAcc + 5);
const scaleX = (elapsed) => padding.left + (elapsed / maxTime) * chartW;
const scaleY = (wpm) => padding.top + chartH - ((wpm - minWpm) / ((maxWpm - minWpm) || 1)) * chartH;
const scaleAccY = (acc) => padding.top + chartH - ((acc - minAcc) / ((maxAcc - minAcc) || 1)) * chartH;
const invertScaleX = (x) => ((x - padding.left) / chartW) * maxTime;
// Clear canvas
ctx.clearRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const val = minWpm + (maxWpm - minWpm) * (i / yTicks);
const y = scaleY(val);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(w - padding.right, y);
ctx.stroke();
// Y-axis labels (WPM - left)
if (showSpeed) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(val), padding.left - 6, y + 3);
}
}
// Right Y-axis labels (Accuracy)
if (showAccuracy) {
for (let i = 0; i <= yTicks; i++) {
const accVal = minAcc + ((maxAcc - minAcc) * i / yTicks);
const y = scaleAccY(accVal);
ctx.fillStyle = 'rgba(75,192,75,0.4)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(accVal.toFixed(0) + '%', w - padding.right + 6, y + 3);
}
}
// X-axis labels
const xTicks = Math.min(6, Math.ceil(maxTime / 5));
for (let i = 0; i <= xTicks; i++) {
const val = (maxTime / xTicks) * i;
const x = scaleX(val);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(Math.round(val) + 's', x, h - padding.bottom + 16);
}
// Axis labels
if (showSpeed) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.save();
ctx.translate(12, padding.top + chartH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('WPM', 0, 0);
ctx.restore();
}
if (showAccuracy) {
ctx.fillStyle = 'rgba(75,192,75,0.3)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.save();
ctx.translate(w - 8, padding.top + chartH / 2);
ctx.rotate(Math.PI / 2);
ctx.fillText('Accuracy', 0, 0);
ctx.restore();
}
// Draw nitro markers (behind lines)
if (showNitros) {
visibleSeries.forEach(s => {
if (!s.nitroMarkers || s.nitroMarkers.length === 0) return;
s.nitroMarkers.forEach(elapsed => {
const x = scaleX(elapsed);
// Dashed vertical line
ctx.strokeStyle = '#1c99f4';
ctx.lineWidth = 1;
ctx.globalAlpha = s.isMe ? 0.5 : 0.2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, padding.top + chartH);
ctx.stroke();
ctx.setLineDash([]);
// Triangle marker at top
ctx.fillStyle = s.color;
ctx.globalAlpha = s.isMe ? 0.8 : 0.3;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x - 4, padding.top - 8);
ctx.lineTo(x + 4, padding.top - 8);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
});
});
}
// Draw accuracy lines (dashed, all visible racers)
if (showAccuracy) {
const sortedAcc = [...visibleSeries].sort((a, b) => (a.isMe ? 1 : 0) - (b.isMe ? 1 : 0));
sortedAcc.forEach(s => {
if (!s.accuracyPoints || s.accuracyPoints.length < 2) return;
ctx.strokeStyle = s.color;
ctx.lineWidth = s.isMe ? 1.5 : 1;
ctx.globalAlpha = s.isMe ? 0.4 : 0.15;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([4, 4]);
ctx.beginPath();
s.accuracyPoints.forEach((d, i) => {
const x = scaleX(d.elapsed);
const y = scaleAccY(d.accuracy);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
});
}
// Draw WPM lines (current user on top)
if (showSpeed) {
const sorted = [...visibleSeries].sort((a, b) => (a.isMe ? 1 : 0) - (b.isMe ? 1 : 0));
sorted.forEach(s => {
if (s.data.length < 2) return;
ctx.strokeStyle = s.color;
ctx.lineWidth = s.isMe ? 2.5 : 1.5;
ctx.globalAlpha = s.isMe ? 1 : 0.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
s.data.forEach((d, i) => {
const x = scaleX(d.elapsed);
const y = scaleY(d.wpm);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.globalAlpha = 1;
});
}
const hoverMouse = chartState.hoverMouse;
if (hoverMouse && chartW > 0 && chartH > 0 && visibleSeries.length > 0) {
const withinChart =
hoverMouse.x >= padding.left &&
hoverMouse.x <= w - padding.right &&
hoverMouse.y >= padding.top &&
hoverMouse.y <= h - padding.bottom;
if (withinChart) {
const targetElapsed = Math.max(0, Math.min(maxTime, invertScaleX(hoverMouse.x)));
let bestHover = null;
const considerHoverPoint = (seriesEntry, point, x, y, color) => {
if (!point || !Number.isFinite(x) || !Number.isFinite(y)) return;
const dx = hoverMouse.x - x;
const dy = hoverMouse.y - y;
const distance = Math.sqrt((dx * dx) + (dy * dy));
if (!bestHover || distance < bestHover.distance) {
bestHover = { seriesEntry, point, x, y, color, distance };
}
};
if (showSpeed) {
visibleSeries.forEach((s) => {
if (!s.data?.length) return;
const point = getNearestCurvePoint(s.data, targetElapsed);
if (point) {
considerHoverPoint(s, point, scaleX(point.elapsed), scaleY(point.wpm), s.color);
}
});
} else if (showAccuracy) {
visibleSeries.forEach((s) => {
if (!s.accuracyPoints?.length) return;
const point = getNearestCurvePoint(s.accuracyPoints, targetElapsed);
if (point) {
considerHoverPoint(s, point, scaleX(point.elapsed), scaleAccY(point.accuracy), s.color);
}
});
}
if (bestHover) {
const hoverSpeedPoint = showSpeed
? getNearestCurvePoint(bestHover.seriesEntry.rawData || bestHover.seriesEntry.data, bestHover.point.elapsed)
: null;
const hoverTrendPoint = showSpeed
? getNearestCurvePoint(bestHover.seriesEntry.data, bestHover.point.elapsed)
: null;
const hoverAccuracyPoint = showAccuracy
? getNearestCurvePoint(bestHover.seriesEntry.accuracyPoints, bestHover.point.elapsed)
: null;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(bestHover.x, padding.top);
ctx.lineTo(bestHover.x, padding.top + chartH);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(bestHover.x, bestHover.y, 6, 0, Math.PI * 2);
ctx.fillStyle = bestHover.color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
const tooltipLines = [
{ text: `${bestHover.seriesEntry.name}${bestHover.seriesEntry.isMe ? ' (You)' : ''}`, color: '#ffffff' },
{ text: `${bestHover.point.elapsed.toFixed(2)}s`, color: 'rgba(255,255,255,0.72)' },
...(hoverSpeedPoint ? [{ text: `${hoverSpeedPoint.wpm} WPM`, color: '#f3a81b' }] : []),
...(hoverAccuracyPoint ? [{ text: `${hoverAccuracyPoint.accuracy.toFixed(2)}% Acc`, color: '#4ade80' }] : []),
...(hoverTrendPoint && hoverSpeedPoint && Math.abs(hoverTrendPoint.wpm - hoverSpeedPoint.wpm) >= 1
? [{
text: `${bestHover.seriesEntry.trendWindow || 3}-pt trend: ${hoverTrendPoint.wpm} WPM`,
color: 'rgba(255,255,255,0.5)'
}]
: [])
];
ctx.font = '12px Montserrat, sans-serif';
const lineHeight = 17;
const tooltipPaddingX = 10;
const tooltipPaddingY = 8;
const tooltipWidth = Math.max(...tooltipLines.map((line) => ctx.measureText(line.text).width)) + (tooltipPaddingX * 2);
const tooltipHeight = (tooltipLines.length * lineHeight) + (tooltipPaddingY * 2) - 4;
let tooltipX = bestHover.x + 14;
let tooltipY = bestHover.y - tooltipHeight - 12;
if (tooltipX + tooltipWidth > w - 8) {
tooltipX = bestHover.x - tooltipWidth - 14;
}
if (tooltipX < 8) {
tooltipX = 8;
}
if (tooltipY < 8) {
tooltipY = bestHover.y + 14;
}
if (tooltipY + tooltipHeight > h - 8) {
tooltipY = h - tooltipHeight - 8;
}
ctx.fillStyle = 'rgba(9, 14, 24, 0.94)';
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 8);
ctx.fill();
ctx.stroke();
tooltipLines.forEach((line, index) => {
ctx.fillStyle = line.color;
ctx.textAlign = 'left';
ctx.fillText(line.text, tooltipX + tooltipPaddingX, tooltipY + tooltipPaddingY + 11 + (index * lineHeight));
});
ctx.restore();
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Summary Stats Cache (prefetched on every page load)
// ─────────────────────────────────────────────────────────────────────────────
const SUMMARY_CACHE_KEY = 'estats-summary-cache';
const SUMMARY_CACHE_TTL = 20 * 60 * 1000; // 20 minutes, matching NT's own delay
const SUMMARY_SEASON_MAX_AGE_SECS = 90 * 24 * 60 * 60;
let _activeSeasonRecordPromise = null;
let _activeSeasonRecordCache = null;
function computeSummaryStats(logs) {
if (logs.length === 0) return null;
const races = logs.length;
const totalTyped = logs.reduce((s, r) => s + (r.typed || 0), 0);
const totalSecs = logs.reduce((s, r) => s + (r.secs || 0), 0);
const totalErrs = logs.reduce((s, r) => s + (r.errs || 0), 0);
const totalXp = logs.reduce((s, r) => s + ((r.reward && r.reward.exp) || 0), 0);
const wpmPerRace = logs.map(r => r.secs > 0 ? (r.typed / 5) / (r.secs / 60) : 0);
const avgWpm = Math.round(wpmPerRace.reduce((s, v) => s + v, 0) / races);
const accPerRace = logs.map(r => r.typed > 0 ? (1 - (r.errs || 0) / r.typed) * 100 : 100);
const avgAcc = Math.round(accPerRace.reduce((s, v) => s + v, 0) / races * 100) / 100;
return { races, totalTyped, totalSecs, totalErrs, totalXp, avgWpm, avgAcc };
}
function getSummaryCache(expectedSeasonKey) {
try {
const raw = localStorage.getItem(SUMMARY_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (cached.ts && (Date.now() - cached.ts) < SUMMARY_CACHE_TTL) {
if (expectedSeasonKey !== undefined && (cached.seasonKey || null) !== (expectedSeasonKey || null)) {
return null;
}
return cached;
}
}
} catch { /* ignore corrupt cache */ }
return null;
}
function parseActiveSeasonsFromBootstrapPayload(bootstrapData) {
if (!Array.isArray(bootstrapData)) return null;
const seasonsData = bootstrapData.find(item => Array.isArray(item) && item[0] === 'ACTIVE_SEASONS');
if (!seasonsData || !Array.isArray(seasonsData[1]) || seasonsData[1].length === 0) return null;
return seasonsData[1];
}
function parseActiveSeasonsFromBootstrapScript(scriptText) {
if (!scriptText || typeof scriptText !== 'string') return null;
const match = scriptText.match(/\["ACTIVE_SEASONS",(\[[\s\S]*?\])\],\["ACTIVE_EVENTS"/);
if (!match || !match[1]) return null;
try {
const parsed = JSON.parse(match[1]);
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
function pickActiveSeason(seasons) {
if (!Array.isArray(seasons) || seasons.length === 0) return null;
const now = Math.floor(Date.now() / 1000);
const active = seasons.find(season => now >= Number(season?.startStamp || 0) && now < Number(season?.endStamp || 0));
if (active) return active;
return seasons.slice().sort((a, b) => Number(b?.startStamp || 0) - Number(a?.startStamp || 0))[0] || null;
}
function getSeasonSummaryKey(seasonRecord) {
if (!seasonRecord) return null;
const seasonID = seasonRecord.seasonID ?? seasonRecord.id ?? seasonRecord.name ?? 'season';
const startStamp = Number(seasonRecord.startStamp || 0);
const endStamp = Number(seasonRecord.endStamp || 0);
return `${seasonID}:${startStamp}:${endStamp}`;
}
function isSeasonSummaryEligible(seasonRecord) {
const startStamp = Number(seasonRecord?.startStamp || 0);
if (!Number.isFinite(startStamp) || startStamp <= 0) return false;
const now = Math.floor(Date.now() / 1000);
return (now - startStamp) <= SUMMARY_SEASON_MAX_AGE_SECS;
}
async function getActiveSeasonRecord() {
if (_activeSeasonRecordCache) return _activeSeasonRecordCache;
if (_activeSeasonRecordPromise) return _activeSeasonRecordPromise;
_activeSeasonRecordPromise = (async () => {
try {
const directSeasons = pageWindow.NTGLOBALS?.ACTIVE_SEASONS;
const directSeason = pickActiveSeason(directSeasons);
if (directSeason) {
_activeSeasonRecordCache = directSeason;
return directSeason;
}
if (typeof pageWindow.NTBOOTSTRAP === 'function') {
try {
const bootstrapData = pageWindow.NTBOOTSTRAP();
const bootstrapSeason = pickActiveSeason(parseActiveSeasonsFromBootstrapPayload(bootstrapData));
if (bootstrapSeason) {
_activeSeasonRecordCache = bootstrapSeason;
return bootstrapSeason;
}
} catch { /* ignore */ }
}
const bootstrapSrc = document.querySelector('script[src*="/bootstrap.js"]')?.src;
if (!bootstrapSrc) return null;
const response = await fetch(bootstrapSrc, { credentials: 'same-origin' });
if (!response.ok) return null;
const scriptText = await response.text();
const bootstrapSeason = pickActiveSeason(parseActiveSeasonsFromBootstrapScript(scriptText));
if (bootstrapSeason) {
_activeSeasonRecordCache = bootstrapSeason;
return bootstrapSeason;
}
} catch (error) {
console.warn(LOG_PREFIX, 'Season metadata lookup failed:', error);
} finally {
_activeSeasonRecordPromise = null;
}
return null;
})();
return _activeSeasonRecordPromise;
}
async function prefetchSummaryStats(seasonRecord = null) {
if (!isFeatureEnabled('ENABLE_ENHANCED_STATS_PAGE')) return;
const seasonEligible = isSeasonSummaryEligible(seasonRecord);
const seasonKey = seasonEligible ? getSeasonSummaryKey(seasonRecord) : null;
// Skip if cache is still fresh
if (getSummaryCache(seasonKey)) return;
const nowSecs = Math.floor(Date.now() / 1000);
const sevenDaysAgo = nowSecs - (7 * 24 * 60 * 60);
const oneDayAgo = nowSecs - (24 * 60 * 60);
const rangeStart = seasonEligible
? Math.min(sevenDaysAgo, Number(seasonRecord.startStamp || nowSecs))
: sevenDaysAgo;
const rangeLogs = [];
try {
let page = 0;
let done = false;
while (!done) {
const data = await apiFetch(`/api/v2/stats/data/racelog?page=${page}&limit=30`);
const logs = data?.results?.logs;
if (!logs || !Array.isArray(logs) || logs.length === 0) break;
for (const log of logs) {
if (log.stamp >= rangeStart) {
rangeLogs.push(log);
} else {
done = true;
break;
}
}
page++;
}
} catch (e) {
console.error(LOG_PREFIX, 'Summary prefetch error:', e);
return;
}
const weekLogs = rangeLogs.filter(r => r.stamp >= sevenDaysAgo);
const dayLogs = weekLogs.filter(r => r.stamp >= oneDayAgo);
const dayStats = computeSummaryStats(dayLogs);
const weekStats = computeSummaryStats(weekLogs);
const seasonStats = seasonEligible
? computeSummaryStats(rangeLogs.filter(r => {
const stamp = Number(r?.stamp || 0);
return stamp >= Number(seasonRecord.startStamp || 0) && stamp < Number(seasonRecord.endStamp || nowSecs + 1);
}))
: null;
try {
localStorage.setItem(SUMMARY_CACHE_KEY, JSON.stringify({
ts: Date.now(),
dayStats,
weekStats,
seasonStats,
seasonKey,
seasonName: seasonEligible ? String(seasonRecord?.name || 'Season') : null
}));
} catch { /* storage full, ignore */ }
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 6: Enhanced Stats Page (/stats)
// ─────────────────────────────────────────────────────────────────────────────
const STATS_PAGE_ATTR = 'data-estats-stats-page';
const STATS_SUMMARY_NOSORT_ATTR = 'data-estats-summary-nosort';
const STATS_SUMMARY_HEADER_LOCK_ATTR = 'data-estats-summary-header-locked';
function cleanupEnhancedStatsPage() {
const statBoxContainer = document.querySelector('.stat-box--container');
if (!statBoxContainer) return;
statBoxContainer.querySelectorAll('[data-estats-injected],[data-estats-lifetime],[data-estats-week]').forEach((el) => el.remove());
statBoxContainer.querySelectorAll('[data-estats-original-text]').forEach((el) => restoreOriginalText(el));
statBoxContainer.querySelectorAll('[data-estats-original-style]').forEach((el) => restoreOriginalStyle(el));
statBoxContainer.querySelectorAll('[data-estats-original-colspan]').forEach((el) => {
const original = el.getAttribute('data-estats-original-colspan');
if (original === '__none__') {
el.removeAttribute('colspan');
} else {
el.setAttribute('colspan', original);
}
el.removeAttribute('data-estats-original-colspan');
});
statBoxContainer.removeAttribute(STATS_PAGE_ATTR);
statBoxContainer.querySelectorAll(`[${STATS_SUMMARY_NOSORT_ATTR}]`).forEach((el) => el.removeAttribute(STATS_SUMMARY_NOSORT_ATTR));
statBoxContainer.querySelectorAll(`[${STATS_PAGE_ATTR}]`).forEach((el) => el.removeAttribute(STATS_PAGE_ATTR));
}
function lockStatsSummaryTableHeaders(table) {
if (!table) return;
table.setAttribute(STATS_SUMMARY_NOSORT_ATTR, '');
table.removeAttribute('data-estats-sortable');
table.removeAttribute('data-estats-sort-active');
table.querySelectorAll('[data-estats-sort-arrow]').forEach((el) => el.remove());
table.querySelectorAll('thead th.table-cell').forEach((th) => {
if (!th.hasAttribute(STATS_SUMMARY_HEADER_LOCK_ATTR)) {
th.setAttribute(STATS_SUMMARY_HEADER_LOCK_ATTR, '');
th.addEventListener('click', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
}, true);
}
th.style.cursor = 'default';
th.style.userSelect = '';
th.title = '';
});
}
/** Create a stat-box--extra element matching NT's native pattern. */
const createStatExtra = (title, value, label = '') => {
const extra = document.createElement('div');
extra.className = 'stat-box--extra';
extra.setAttribute('data-estats-injected', '');
extra.innerHTML = `
<div class="stat-box--extra--title">${escapeHtml(title)}</div>
<div class="stat-box--extra--value">
<div class="stat-box--extra--stat">${value}</div>
${label ? `<div class="stat-box--extra--label">${escapeHtml(label)}</div>` : ''}
</div>
`;
return extra;
};
/** Create a summary table row matching NT's native pattern. */
const createSummaryRow = (label, races, avgSpeed, avgAcc, extraCols = []) => {
const tr = document.createElement('tr');
tr.className = 'table-row';
tr.setAttribute('data-estats-injected', '');
let html = `
<td class="table-cell"><span class="tsm tc-ts">${escapeHtml(label)}</span></td>
<td class="table-cell tar">${escapeHtml(String(races))}</td>
<td class="table-cell tar">${escapeHtml(String(avgSpeed))} <span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell tar">${escapeHtml(String(avgAcc))}<span class="tsxs tc-ts">%</span></td>
`;
extraCols.forEach(col => {
html += `<td class="table-cell tar">${col}</td>`;
});
tr.innerHTML = html;
return tr;
};
async function handleEnhancedStatsPage() {
if (!isFeatureEnabled('ENABLE_ENHANCED_STATS_PAGE')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/stats') return;
const statBoxContainer = document.querySelector('.stat-box--container');
if (!statBoxContainer) return;
if (statBoxContainer.hasAttribute(STATS_PAGE_ATTR)) return;
statBoxContainer.setAttribute(STATS_PAGE_ATTR, '');
const user = getCurrentUser();
if (!user) return;
// ── Fix equal-height gaps: auto-size rows, pack to top, no extras gap ──
statBoxContainer.querySelectorAll('.stat-box').forEach(box => {
rememberOriginalStyle(box);
box.style.setProperty('grid-template-rows', 'auto auto auto', 'important');
box.style.setProperty('align-content', 'start', 'important');
const extras = box.querySelector('.stat-box--extras');
if (extras) {
rememberOriginalStyle(extras);
extras.style.setProperty('padding-top', '0', 'important');
extras.style.setProperty('margin-top', '0', 'important');
}
const hr = box.querySelector('hr');
if (hr) {
rememberOriginalStyle(hr);
hr.style.setProperty('margin-bottom', '0', 'important');
hr.style.setProperty('margin-top', '8px', 'important');
}
});
// ── Rename Speed → Performance + inject stats ─────────────────────
const speedBoxEl = statBoxContainer.querySelector('.stat-box--speed');
if (speedBoxEl) {
const speedTitle = speedBoxEl.querySelector('.stat-box--title');
rememberOriginalText(speedTitle);
if (speedTitle) speedTitle.textContent = 'Performance';
// Add "Speed" label above WPM, "Accuracy" label above accuracy %, rename bottom to "Average"
if (user.avgAcc != null) {
const details = speedBoxEl.querySelector('.stat-box--details');
const statEl = speedBoxEl.querySelector('.stat-box--stat');
const labelEl = speedBoxEl.querySelector('.stat-box--label');
if (details && statEl && labelEl) {
rememberOriginalText(labelEl);
// Insert "Speed" label above the WPM number
const speedLabel = document.createElement('div');
speedLabel.className = 'stat-box--label';
speedLabel.setAttribute('data-estats-injected', '');
speedLabel.style.color = '#e8796b';
speedLabel.textContent = 'Speed';
statEl.before(speedLabel);
// Change "Average" to just be the bottom label
labelEl.textContent = 'Average';
// Insert "Accuracy" label + value after the existing Average label
const accTitle = document.createElement('div');
accTitle.className = 'stat-box--label';
accTitle.setAttribute('data-estats-injected', '');
accTitle.style.color = '#e8796b';
accTitle.textContent = 'Accuracy';
labelEl.after(accTitle);
const accStat = document.createElement('div');
accStat.className = 'stat-box--stat';
accStat.setAttribute('data-estats-injected', '');
accStat.innerHTML = `${user.avgAcc}<span>%</span>`;
accTitle.after(accStat);
const accAvgLabel = document.createElement('div');
accAvgLabel.className = 'stat-box--label';
accAvgLabel.setAttribute('data-estats-injected', '');
accAvgLabel.textContent = 'Average';
accStat.after(accAvgLabel);
}
}
}
// ── Rename Races → Racing + inject stats ─────────────────────────
const racesBoxEl = statBoxContainer.querySelector('.stat-box--races');
if (racesBoxEl) {
const racesTitle = racesBoxEl.querySelector('.stat-box--title');
rememberOriginalText(racesTitle);
if (racesTitle) racesTitle.textContent = 'Racing';
}
const racesBox = statBoxContainer.querySelector('.stat-box--races .stat-box--extras');
if (racesBox) {
if (user.wampusWins != null) {
racesBox.appendChild(createStatExtra('Wampus Wins', (user.wampusWins || 0).toLocaleString(), 'Wins'));
}
}
// ── Streaks widget next to "Overview" heading ────────────────────
const flameSvg = '<svg viewBox="0 0 24 24" width="16" height="16" style="vertical-align:-2px;"><path fill="#f3a81b" d="M12 23c-4.97 0-9-3.58-9-8 0-3.07 1.74-6.07 4.5-7.77.37-.23.85.1.75.53-.29 1.2-.04 2.5.69 3.47C9.41 7.73 11.2 4.78 11.6 1.48c.05-.4.53-.54.79-.22C14.54 4.14 17 7.61 17 11c0 1.2-.28 2.37-.82 3.42.44-.47.74-1.05.87-1.69.07-.34.53-.43.73-.14C18.55 13.74 19 15.08 19 16.5c0 3.59-3.13 6.5-7 6.5z"/></svg>';
// Find the "Overview" heading's parent .row
const overviewRow = Array.from(document.querySelectorAll('.row')).find(row => {
const h = row.querySelector('h3');
return h && h.textContent.trim() === 'Overview';
});
if (overviewRow && !overviewRow.querySelector('[data-estats-injected]')) {
rememberOriginalStyle(overviewRow);
overviewRow.style.display = 'flex';
overviewRow.style.alignItems = 'center';
overviewRow.style.justifyContent = 'space-between';
const streaksWidget = document.createElement('div');
streaksWidget.setAttribute('data-estats-injected', '');
streaksWidget.style.cssText = 'display:flex;gap:20px;align-items:center;';
const winStreak = user.consecWins || 0;
const dayStreak = user.consecDaysRaced || 0;
streaksWidget.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;">
${flameSvg}
<span class="tsm twb" style="color:#f3a81b;">${winStreak}</span>
<span class="tsxs tc-ts">Win Streak</span>
</div>
<div style="display:flex;align-items:center;gap:6px;">
${flameSvg}
<span class="tsm twb" style="color:#f3a81b;">${dayStreak}</span>
<span class="tsxs tc-ts">Day Streak</span>
</div>
`;
overviewRow.appendChild(streaksWidget);
}
// ── Rename Experience → Progression + inject stats ─────────────────
const xpBoxEl = statBoxContainer.querySelector('.stat-box--progress');
if (xpBoxEl) {
const xpTitle = xpBoxEl.querySelector('.stat-box--title');
rememberOriginalText(xpTitle);
if (xpTitle) xpTitle.textContent = 'Progression';
}
const xpBox = statBoxContainer.querySelector('.stat-box--progress .stat-box--extras');
if (xpBox) {
// Find the NT native "calculated as of" note to insert before it
const noteEl = xpBox.querySelector('.tsxs.tc-ts');
// Order: Player Level, Season Level, Season XP, then the native note
const insertBeforeNote = (el) => {
if (noteEl) {
xpBox.insertBefore(el, noteEl);
} else {
xpBox.appendChild(el);
}
};
// ── Shared progress bar builder ──────────────────────────────
const createProgressBar = (progress, needed, gradient, tooltipHtml) => {
const pct = needed > 0 ? Math.min(100, Math.max(0, (progress / needed) * 100)) : 0;
const widget = document.createElement('div');
widget.setAttribute('data-estats-injected', '');
widget.style.cssText = 'margin:-4px 0 10px;padding:0 2px;position:relative;';
const track = document.createElement('div');
track.style.cssText = 'height:6px;border-radius:999px;background:rgba(0,0,0,0.22);box-shadow:inset 0 1px 2px rgba(0,0,0,0.35);overflow:hidden;';
const fill = document.createElement('div');
fill.style.cssText = `width:${pct.toFixed(1)}%;height:100%;border-radius:999px;background:${gradient};box-shadow:0 1px 0 rgba(255,255,255,0.25) inset,0 2px 6px rgba(0,0,0,0.25);transition:width 0.3s ease;`;
track.appendChild(fill);
const label = document.createElement('div');
label.style.cssText = 'font-size:11px;color:#a6aac1;margin-top:3px;display:inline-flex;align-items:center;gap:2px;';
label.textContent = `${progress.toLocaleString()} / ${needed.toLocaleString()} XP`;
if (tooltipHtml) {
const infoWrap = document.createElement('span');
infoWrap.style.cssText = 'position:relative;display:inline-block;vertical-align:middle;margin-left:2px;cursor:help;';
infoWrap.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" fill="#a6aac1" style="display:block;"><path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 2.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM6.5 7h2l.5.5V12h-1V7.5H6.5V7Z"/></svg>';
const tip = document.createElement('div');
tip.style.cssText = 'display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#1d2030;color:#d8dcf2;font-size:11px;line-height:1.4;padding:8px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 12px rgba(0,0,0,0.4);white-space:nowrap;z-index:100;pointer-events:none;';
tip.innerHTML = tooltipHtml;
const arrow = document.createElement('div');
arrow.style.cssText = 'position:absolute;bottom:-4px;left:50%;transform:translateX(-50%) rotate(45deg);width:7px;height:7px;background:#1d2030;border-right:1px solid rgba(255,255,255,0.12);border-bottom:1px solid rgba(255,255,255,0.12);';
tip.appendChild(arrow);
infoWrap.appendChild(tip);
infoWrap.addEventListener('mouseenter', () => { tip.style.display = 'block'; });
infoWrap.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
label.appendChild(infoWrap);
}
widget.appendChild(track);
widget.appendChild(label);
return widget;
};
const GOLD_GRADIENT = 'linear-gradient(90deg,#f7d14a 0%,#f3a81b 55%,#f07d1b 100%)';
const BLUE_GRADIENT = 'linear-gradient(90deg,#1c99f4 0%,#167ac3 100%)';
// ── Player Level + progress bar ──────────────────────────────
if (user.playerLevel != null) {
insertBeforeNote(createStatExtra('Player Level', (user.playerLevel || 0).toLocaleString(), ''));
const pXp = user.playerExperience || 0;
const plCfg = pageWindow.NTGLOBALS?.PLAYER_LEVELS;
const plBase = (plCfg && Number.isFinite(plCfg.basePoints)) ? plCfg.basePoints : 500;
const plMult = (plCfg && Number.isFinite(plCfg.multiple)) ? plCfg.multiple : 2;
const pLvl = Math.max(1, user.playerLevel || 1);
const xpFloor = getPlayerXpThreshold(pLvl - 1);
const xpCeil = getPlayerXpThreshold(pLvl);
const xpNeeded = Math.max(0, xpCeil - xpFloor);
const xpProgressRaw = Math.max(0, pXp - xpFloor);
const xpProgress = xpNeeded > 0 ? Math.min(xpNeeded, xpProgressRaw) : 0;
const xpRemaining = Math.max(0, xpCeil - pXp);
const plTooltip = `<div style="margin-bottom:4px;font-weight:600;color:#f3a81b;">Player Level Thresholds</div>`
+ `<div>Threshold(n) = (${plBase.toLocaleString()} × ${plMult.toLocaleString()} × n²) + (${plBase.toLocaleString()} × n)</div>`
+ `<div style="margin-top:4px;color:#a6aac1;">Level ${pLvl.toLocaleString()} spans ${xpFloor.toLocaleString()} to ${xpCeil.toLocaleString()} XP</div>`
+ `<div style="margin-top:4px;color:#a6aac1;">Next level at ${xpCeil.toLocaleString()} XP (${xpRemaining.toLocaleString()} to go)</div>`;
insertBeforeNote(createProgressBar(xpProgress, xpNeeded, GOLD_GRADIENT, plTooltip));
}
// ── Season Level + progress bar ──────────────────────────────
if (user.level != null && user.experience != null) {
insertBeforeNote(createStatExtra('Season Level', formatSeasonLevel(user.level) || user.level, ''));
const sLvl = user.level || 1;
const sXp = user.experience || 0;
const sl = pageWindow.NTGLOBALS?.SEASON_LEVELS;
const season = pageWindow.NTGLOBALS?.ACTIVE_SEASONS?.[0];
const startingLevels = sl?.startingLevels || 3;
const xpPerStarting = sl?.experiencePerStartingLevel || 10000;
const xpPerAchievement = sl?.experiencePerAchievementLevel || 20000;
const xpPerExtra = sl?.experiencePerExtraLevels || 40000;
const totalRewards = season?.totalRewards || 32;
// Calculate cumulative XP thresholds for the current and next level
const getSeasonXpThreshold = (lvl) => {
let total = 0;
for (let i = 1; i < lvl; i++) {
if (i <= startingLevels) {
total += xpPerStarting;
} else if (i > totalRewards) {
total += xpPerExtra;
} else {
total += xpPerAchievement;
}
}
return total;
};
const sFloor = getSeasonXpThreshold(sLvl);
const sCeil = getSeasonXpThreshold(sLvl + 1);
const sProgress = Math.max(0, sXp - sFloor);
const sNeeded = sCeil - sFloor;
const tierLabel = sLvl <= startingLevels ? `${(xpPerStarting / 1000).toLocaleString()}k per level (starting)`
: sLvl > totalRewards ? `${(xpPerExtra / 1000).toLocaleString()}k per level (bonus)`
: `${(xpPerAchievement / 1000).toLocaleString()}k per level`;
const sTooltip = `<div style="margin-bottom:4px;font-weight:600;color:#1c99f4;">Season Level Progression</div>`
+ `<div>${tierLabel}</div>`
+ `<div style="margin-top:4px;color:#a6aac1;">Next level at ${sCeil.toLocaleString()} XP (${(sCeil - sXp).toLocaleString()} to go)</div>`;
insertBeforeNote(createProgressBar(sProgress, sNeeded, GOLD_GRADIENT, sTooltip));
} else {
if (user.level != null) {
insertBeforeNote(createStatExtra('Season Level', formatSeasonLevel(user.level) || user.level, ''));
}
if (user.experience != null) {
insertBeforeNote(createStatExtra('Season XP', (user.experience || 0).toLocaleString(), 'XP'));
}
}
}
// ── Rename Money box to Assets + inject stats ─────────────────────
const moneyBoxEl = statBoxContainer.querySelector('.stat-box--money');
if (moneyBoxEl) {
// Rename the title from "Money" to "Assets"
const moneyTitle = moneyBoxEl.querySelector('.stat-box--title');
rememberOriginalText(moneyTitle);
if (moneyTitle) moneyTitle.textContent = 'Assets';
const moneyBox = moneyBoxEl.querySelector('.stat-box--extras');
if (moneyBox) {
const totalCars = user.totalCars || user.carsOwned || 0;
if (totalCars > 0) {
moneyBox.appendChild(createStatExtra('Cars Owned', totalCars.toLocaleString(), 'Cars'));
}
if (user.nitros != null) {
moneyBox.appendChild(createStatExtra('Nitros Owned', (user.nitros || 0).toLocaleString(), ''));
}
}
}
// ── Inject into Membership box ─────────────────────────────────────
const memberBox = statBoxContainer.querySelector('.stat-box--gold .stat-box--extras');
if (memberBox) {
if (user.playTime != null) {
memberBox.appendChild(createStatExtra('Play Time', formatDuration(user.playTime || 0), ''));
}
}
// ── Enhance Summary Table ──────────────────────────────────────────
// Add extra column headers: Errors, Total Time
const summaryTable = document.querySelector('.table--striped');
if (summaryTable) {
const headerRow = summaryTable.querySelector('thead .table-row');
if (headerRow && !headerRow.querySelector('[data-estats-injected]')) {
// Keep NT's native header styling
const extraHeaders = [
{ label: 'Errors', color: '#d62f3a' },
{ label: 'Experience', color: '#f3a81b' },
{ label: 'Total Time', color: '#1c99f4' }
];
extraHeaders.forEach(({ label, color }) => {
const th = document.createElement('th');
th.scope = 'col';
th.className = 'table-cell';
th.setAttribute('data-estats-injected', '');
th.style.color = color;
th.textContent = label;
headerRow.appendChild(th);
});
}
const tbody = summaryTable.querySelector('tbody');
if (tbody && !tbody.querySelector('[data-estats-lifetime]')) {
// Add placeholder cells to existing rows immediately to prevent layout shift
tbody.querySelectorAll('.table-row').forEach(row => {
if (row.querySelector('[data-estats-injected]')) return;
for (let i = 0; i < 3; i++) {
const td = document.createElement('td');
td.className = 'table-cell tar';
td.setAttribute('data-estats-injected', '');
td.setAttribute('data-estats-placeholder', i === 0 ? 'errors' : i === 1 ? 'xp' : 'time');
td.textContent = '…';
td.style.opacity = '0.3';
row.appendChild(td);
}
});
// Add Lifetime row with placeholders immediately
const totalRaces = user.racesPlayed || 0;
const avgSpeed = user.avgSpeed || 0;
const avgAcc = user.avgAcc || 0;
const lifetimeXp = user.playerExperience || user.experience || 0;
const totalMinutes = (user.playTime || 0) / 60;
const totalChars = avgSpeed * totalMinutes * 5;
const lifetimeErrors = avgAcc > 0 ? Math.round(totalChars * (1 - avgAcc / 100)) : 0;
const lifetimeRow = document.createElement('tr');
lifetimeRow.className = 'table-row';
lifetimeRow.setAttribute('data-estats-lifetime', '');
lifetimeRow.setAttribute('data-estats-injected', '');
lifetimeRow.innerHTML = `
<td class="table-cell"><span class="tsm tc-ts">Lifetime</span></td>
<td class="table-cell tar">${totalRaces.toLocaleString()}</td>
<td class="table-cell tar">${avgSpeed} <span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell tar">${avgAcc}<span class="tsxs tc-ts">%</span></td>
<td class="table-cell tar">~${lifetimeErrors.toLocaleString()}</td>
<td class="table-cell tar">${lifetimeXp.toLocaleString()} <span class="tsxxs tc-ts mlxxs">XP</span></td>
<td class="table-cell tar">${formatDuration(user.playTime || 0)}</td>
`;
tbody.appendChild(lifetimeRow);
// Update the colspan on the footer
const footerTd = summaryTable.querySelector('tfoot .table-cell[colspan]');
if (footerTd) {
if (!footerTd.hasAttribute('data-estats-original-colspan')) {
const originalColspan = footerTd.getAttribute('colspan');
footerTd.setAttribute('data-estats-original-colspan', originalColspan == null ? '__none__' : originalColspan);
}
footerTd.setAttribute('colspan', '8');
}
// ── Helper: fill the table from stats ──
const fillSummaryStats = (dayStats, weekStats, seasonPayload = null) => {
const bodyRows = summaryTable.querySelectorAll('tbody .table-row:not([data-estats-lifetime])');
bodyRows.forEach(row => {
const cells = row.querySelectorAll('.table-cell');
if (dayStats && cells.length >= 4) {
cells[1].innerHTML = dayStats.races.toLocaleString();
cells[2].innerHTML = `${dayStats.avgWpm} <span class="tsxxs tc-ts mlxxs">WPM</span>`;
cells[3].innerHTML = `${dayStats.avgAcc}<span class="tsxs tc-ts">%</span>`;
}
const errCell = row.querySelector('[data-estats-placeholder="errors"]');
const xpCell = row.querySelector('[data-estats-placeholder="xp"]');
const timeCell = row.querySelector('[data-estats-placeholder="time"]');
if (dayStats) {
if (errCell) { errCell.textContent = dayStats.totalErrs.toLocaleString(); errCell.style.opacity = ''; }
if (xpCell) { xpCell.innerHTML = `${dayStats.totalXp.toLocaleString()} <span class="tsxxs tc-ts mlxxs">XP</span>`; xpCell.style.opacity = ''; }
if (timeCell) { timeCell.textContent = formatDuration(dayStats.totalSecs); timeCell.style.opacity = ''; }
} else {
if (errCell) { errCell.textContent = '—'; errCell.style.opacity = ''; }
if (xpCell) { xpCell.textContent = '—'; xpCell.style.opacity = ''; }
if (timeCell) { timeCell.textContent = '—'; timeCell.style.opacity = ''; }
}
});
const lifetimeRowEl = tbody.querySelector('[data-estats-lifetime]');
let weekRow = tbody.querySelector('[data-estats-week]');
let seasonRow = tbody.querySelector('[data-estats-season]');
if (weekStats && lifetimeRowEl) {
if (!weekRow) {
weekRow = document.createElement('tr');
weekRow.className = 'table-row';
weekRow.setAttribute('data-estats-injected', '');
weekRow.setAttribute('data-estats-week', '');
tbody.insertBefore(weekRow, lifetimeRowEl);
}
weekRow.innerHTML = `
<td class="table-cell"><span class="tsm tc-ts">Last 7 Days</span></td>
<td class="table-cell tar">${weekStats.races.toLocaleString()}</td>
<td class="table-cell tar">${weekStats.avgWpm} <span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell tar">${weekStats.avgAcc}<span class="tsxs tc-ts">%</span></td>
<td class="table-cell tar">${weekStats.totalErrs.toLocaleString()}</td>
<td class="table-cell tar">${weekStats.totalXp.toLocaleString()} <span class="tsxxs tc-ts mlxxs">XP</span></td>
<td class="table-cell tar">${formatDuration(weekStats.totalSecs)}</td>
`;
} else if (weekRow) {
weekRow.remove();
}
if (seasonPayload?.stats && lifetimeRowEl) {
if (!seasonRow) {
seasonRow = document.createElement('tr');
seasonRow.className = 'table-row';
seasonRow.setAttribute('data-estats-injected', '');
seasonRow.setAttribute('data-estats-season', '');
tbody.insertBefore(seasonRow, lifetimeRowEl);
}
seasonRow.title = seasonPayload.name || 'Current Season';
seasonRow.innerHTML = `
<td class="table-cell"><span class="tsm tc-ts">Season</span></td>
<td class="table-cell tar">${seasonPayload.stats.races.toLocaleString()}</td>
<td class="table-cell tar">${seasonPayload.stats.avgWpm} <span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell tar">${seasonPayload.stats.avgAcc}<span class="tsxs tc-ts">%</span></td>
<td class="table-cell tar">${seasonPayload.stats.totalErrs.toLocaleString()}</td>
<td class="table-cell tar">${seasonPayload.stats.totalXp.toLocaleString()} <span class="tsxxs tc-ts mlxxs">XP</span></td>
<td class="table-cell tar">${formatDuration(seasonPayload.stats.totalSecs)}</td>
`;
} else if (seasonRow) {
seasonRow.remove();
}
};
const seasonRecord = await getActiveSeasonRecord();
const seasonSummaryEligible = isSeasonSummaryEligible(seasonRecord);
const expectedSeasonKey = seasonSummaryEligible ? getSeasonSummaryKey(seasonRecord) : undefined;
// ── Load from prefetched cache (instant, no shift) ──
const cached = getSummaryCache(expectedSeasonKey);
if (cached) {
fillSummaryStats(
cached.dayStats,
cached.weekStats,
cached.seasonStats && seasonSummaryEligible
? { stats: cached.seasonStats, name: cached.seasonName || seasonRecord?.name || 'Season' }
: null
);
}
// ── If cache was stale/missing, fetch now and fill ──
if (!cached) {
await prefetchSummaryStats(seasonSummaryEligible ? seasonRecord : null);
const fresh = getSummaryCache(expectedSeasonKey);
if (fresh) {
fillSummaryStats(
fresh.dayStats,
fresh.weekStats,
fresh.seasonStats && seasonSummaryEligible
? { stats: fresh.seasonStats, name: fresh.seasonName || seasonRecord?.name || 'Season' }
: null
);
}
}
lockStatsSummaryTableHeaders(summaryTable);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 7: Enhanced Racelog (/racelog/*)
// ─────────────────────────────────────────────────────────────────────────────
const RACELOG_ATTR = 'data-estats-racelog';
const RACELOG_PAGE_ATTR = 'data-estats-racelog-page';
const RACELOG_ROW_PAGE_ATTR = 'data-estats-racelog-row-page';
const RACELOG_HYDRATION_ATTR = 'data-estats-racelog-hydration';
const RACELOG_MODAL_ATTR = 'data-estats-racelog-modal';
const RACELOG_MODAL_ITEM_ATTR = 'data-estats-racelog-modal-item';
const RACELOG_MODAL_ROW_HEIGHT_ATTR = 'data-estats-racelog-original-inline-height';
const RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR = 'data-estats-racelog-original-inline-min-height';
const RACELOG_MODAL_SPLIT_DISPLAY_ATTR = 'data-estats-racelog-original-split-display';
const RACELOG_TREND_BTN_ATTR = 'data-estats-racelog-trend-btn';
const RACELOG_TREND_OVERLAY_ATTR = 'data-estats-racelog-trend-overlay';
const PER_PAGE = 30;
const _dataCache = {};
const _totalRecords = {};
const _sortableTables = new Set();
let _lastEnhancedRacelogWorkKey = '';
function cleanupEnhancedRacelog() {
resetAllRacelogTabSortState();
document.querySelectorAll('[data-estats-sort-pagination]').forEach((el) => el.remove());
restoreVisibleNativePagination();
document.querySelectorAll('.tabs.tabs--a.tabs--fourUp[data-estats-tab-reset-bound]').forEach((el) => {
el.removeAttribute('data-estats-tab-reset-bound');
});
document.querySelectorAll(`table[${RACELOG_ATTR}], .table--striped[data-estats-sortable]`).forEach((table) => {
table.querySelectorAll('[data-estats-injected]').forEach((el) => el.remove());
table.removeAttribute(RACELOG_ATTR);
table.removeAttribute(RACELOG_PAGE_ATTR);
table.removeAttribute(RACELOG_HYDRATION_ATTR);
table.removeAttribute('data-estats-sortable');
table.removeAttribute('data-estats-sort-active');
table.removeAttribute('data-estats-sort-owner-id');
table.querySelectorAll('[data-estats-sort-arrow]').forEach((el) => el.remove());
});
document.querySelectorAll(`[${RACELOG_ROW_PAGE_ATTR}]`).forEach((row) => {
row.removeAttribute(RACELOG_ROW_PAGE_ATTR);
});
document.querySelectorAll(`.modal--raceResults .raceResults[${RACELOG_MODAL_ATTR}]`).forEach((el) => {
el.removeAttribute(RACELOG_MODAL_ATTR);
});
document.querySelectorAll(`.modal--raceResults [${RACELOG_MODAL_ITEM_ATTR}]`).forEach((el) => el.remove());
document.querySelectorAll(`[${RACELOG_TREND_BTN_ATTR}]`).forEach((el) => {
const actionWell = el.parentElement;
el.remove();
if (actionWell) restoreOriginalStyle(actionWell);
});
document.querySelectorAll(`[${RACELOG_TREND_OVERLAY_ATTR}]`).forEach((el) => el.remove());
_sortableTables.clear();
_lastEnhancedRacelogWorkKey = '';
}
let _sortOwnerCounter = 0;
let _deferredSortCleanupTimer = null;
// NT API type mapping: our internal table types → API type parameter values
const API_TYPE_MAP = { racelog: 'racelog', daily: 'lastdays', monthly: 'bymonth', topSpeed: 'topspeeds' };
// ── Shared: fetch all records of a given type (cached, batched) ──
async function fetchAllDataForType(tableType) {
const apiType = API_TYPE_MAP[tableType] || tableType;
if (_dataCache[apiType]) return _dataCache[apiType];
const first = await apiFetch(`/api/v2/stats/data/${apiType}?page=0&limit=${PER_PAGE}`);
const total = first.results?.totalRecords || 0;
_totalRecords[apiType] = total;
const firstLogs = first.results?.logs || [];
if (!Array.isArray(firstLogs)) return [];
if (apiType === 'racelog') {
firstLogs.forEach((r, i) => { r._raceNum = total - i; });
}
if (firstLogs.length >= total) { _dataCache[apiType] = firstLogs; return firstLogs; }
const totalPages = Math.ceil(total / PER_PAGE);
let allLogs = [...firstLogs];
for (let batch = 1; batch < totalPages; batch += 10) {
const promises = [];
for (let p = batch; p < Math.min(batch + 10, totalPages); p++) {
promises.push(apiFetch(`/api/v2/stats/data/${apiType}?page=${p}&limit=${PER_PAGE}`).then(res => ({ page: p, res })));
}
const results = await Promise.all(promises);
results.sort((a, b) => a.page - b.page);
results.forEach(({ page: p, res: r }) => {
const logs = r.results?.logs || [];
if (Array.isArray(logs)) {
if (apiType === 'racelog') {
logs.forEach((race, i) => { race._raceNum = total - (p * PER_PAGE + i); });
}
allLogs = allLogs.concat(logs);
}
});
}
_dataCache[apiType] = allLogs;
return allLogs;
}
async function fetchRacelogPage(pageNum) {
const safePage = Math.max(0, Number(pageNum) || 0);
const cacheKey = `racelog-page-${safePage}`;
if (_dataCache[cacheKey]) return _dataCache[cacheKey];
const data = await apiFetch(`/api/v2/stats/data/racelog?page=${safePage}&limit=${PER_PAGE}`);
const logs = Array.isArray(data?.results?.logs) ? data.results.logs : [];
_dataCache[cacheKey] = logs;
return logs;
}
function formatRacelogTrendLabel(stamp) {
if (!stamp) return '—';
return new Date(stamp * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
function formatRacelogTrendTooltipTimestamp(stamp) {
if (!stamp) return '—';
return new Date(stamp * 1000).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
function formatRacelogTrendAxisLabel(stamp) {
if (!stamp) return { date: '—', time: '' };
const date = new Date(stamp * 1000);
return {
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
time: date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
};
}
function formatDatetimeLocalValue(stamp) {
if (!stamp) return '';
const date = new Date(stamp * 1000);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
function parseDatetimeLocalValue(value) {
if (!value) return null;
const date = new Date(value);
const time = date.getTime();
return Number.isFinite(time) ? Math.floor(time / 1000) : null;
}
function filterRacelogTrendPoints(points, rangeState) {
if (!Array.isArray(points) || points.length === 0) return [];
const mode = rangeState?.mode || 'all';
let startStamp = null;
let endStamp = null;
const nowStamp = Math.floor(Date.now() / 1000);
if (mode === '24h') {
endStamp = nowStamp;
startStamp = nowStamp - (24 * 60 * 60);
} else if (mode === '7d') {
endStamp = nowStamp;
startStamp = nowStamp - (7 * 24 * 60 * 60);
} else if (mode === '30d') {
endStamp = nowStamp;
startStamp = nowStamp - (30 * 24 * 60 * 60);
} else if (mode === 'custom') {
startStamp = Number.isFinite(rangeState?.startStamp) ? rangeState.startStamp : null;
endStamp = Number.isFinite(rangeState?.endStamp) ? rangeState.endStamp : null;
}
return points.filter((point) => {
if (startStamp != null && point.stamp < startStamp) return false;
if (endStamp != null && point.stamp > endStamp) return false;
return true;
});
}
function getNearestIndexedPoint(points, targetIndex) {
if (!Array.isArray(points) || points.length === 0 || !Number.isFinite(targetIndex)) return null;
const index = Math.max(0, Math.min(points.length - 1, Math.round(targetIndex)));
return points[index] || null;
}
function buildRacelogTrendPoints(logs) {
const points = (Array.isArray(logs) ? logs : [])
.filter((race) => Number.isFinite(Number(race?.stamp)) && Number(race.stamp) > 0 && Number.isFinite(Number(race?.secs)) && Number(race.secs) > 0 && Number.isFinite(Number(race?.typed)) && Number(race.typed) > 0)
.sort((a, b) => {
const stampDiff = Number(a.stamp) - Number(b.stamp);
if (stampDiff !== 0) return stampDiff;
return Number(a._raceNum || 0) - Number(b._raceNum || 0);
})
.map((race, index) => ({
stamp: Number(race.stamp),
raceNum: Number(race._raceNum) || index + 1,
wpm: calcWPM(race),
accuracy: Math.round(calcAcc(race) * 100) / 100,
typed: Number(race.typed) || 0,
errors: Number(race.errs) || 0,
secs: Number(race.secs) || 0
}));
const smoothingWindow = points.length >= 500 ? 21
: points.length >= 250 ? 15
: points.length >= 120 ? 9
: points.length >= 50 ? 5
: 3;
return points.map((point, index) => {
const halfWindow = Math.floor(smoothingWindow / 2);
const start = Math.max(0, index - halfWindow);
const end = Math.min(points.length - 1, index + halfWindow);
let sumWpm = 0;
let sumAcc = 0;
let count = 0;
for (let i = start; i <= end; i++) {
sumWpm += points[i].wpm;
sumAcc += points[i].accuracy;
count++;
}
return {
...point,
smoothingWindow,
trendWpm: Math.round(sumWpm / Math.max(1, count)),
trendAccuracy: Math.round((sumAcc / Math.max(1, count)) * 100) / 100
};
});
}
function drawRacelogTrendChart(canvas, points, chartState = {}) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
if (!canvas._origW) { canvas._origW = canvas.width; canvas._origH = canvas.height; }
const w = canvas._origW;
const h = canvas._origH;
const showSpeed = chartState.showSpeed !== false;
const showAccuracy = chartState.showAccuracy !== false;
const useSmoothing = chartState.useSmoothing !== false;
const hoverMouse = chartState.hoverMouse || null;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (!Array.isArray(points) || points.length === 0) return;
const padding = { top: 22, right: showAccuracy ? 54 : 24, bottom: 52, left: 48 };
const chartW = w - padding.left - padding.right;
const chartH = h - padding.top - padding.bottom;
const lastIndex = Math.max(1, points.length - 1);
let minWpm = Infinity;
let maxWpm = 0;
let minAcc = Infinity;
let maxAcc = 0;
points.forEach((point) => {
const wpmValue = useSmoothing ? point.trendWpm : point.wpm;
const accValue = useSmoothing ? point.trendAccuracy : point.accuracy;
if (Number.isFinite(wpmValue)) {
minWpm = Math.min(minWpm, wpmValue);
maxWpm = Math.max(maxWpm, wpmValue);
}
if (Number.isFinite(accValue)) {
minAcc = Math.min(minAcc, accValue);
maxAcc = Math.max(maxAcc, accValue);
}
});
if (!Number.isFinite(minWpm)) minWpm = 0;
if (!Number.isFinite(maxWpm) || maxWpm <= 0) maxWpm = 150;
if (!Number.isFinite(minAcc)) minAcc = 90;
if (!Number.isFinite(maxAcc) || maxAcc <= 0) maxAcc = 100;
minWpm = Math.max(0, Math.floor(minWpm / 10) * 10 - 10);
maxWpm = Math.max(minWpm + 10, Math.ceil(maxWpm / 10) * 10 + 10);
minAcc = Math.max(80, Math.floor(minAcc) - 2);
maxAcc = Math.min(100, Math.ceil(maxAcc) + 1);
if (maxAcc - minAcc < 4) {
minAcc = Math.max(80, minAcc - 2);
maxAcc = Math.min(100, maxAcc + 2);
}
if (maxAcc <= minAcc) maxAcc = Math.min(100, minAcc + 5);
const scaleX = (index) => padding.left + ((index / lastIndex) * chartW);
const scaleSpeedY = (wpm) => padding.top + chartH - (((wpm - minWpm) / (maxWpm - minWpm || 1)) * chartH);
const scaleAccY = (acc) => padding.top + chartH - (((acc - minAcc) / (maxAcc - minAcc || 1)) * chartH);
const invertScaleX = (x) => ((x - padding.left) / chartW) * lastIndex;
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const y = padding.top + ((chartH / yTicks) * i);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(w - padding.right, y);
ctx.stroke();
if (showSpeed) {
const speedValue = maxWpm - (((maxWpm - minWpm) / yTicks) * i);
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(String(Math.round(speedValue)), padding.left - 6, y + 3);
}
if (showAccuracy) {
const accValue = maxAcc - (((maxAcc - minAcc) / yTicks) * i);
ctx.fillStyle = 'rgba(74,222,128,0.45)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${accValue.toFixed(0)}%`, w - padding.right + 6, y + 3);
}
}
const xTicks = Math.min(6, Math.max(2, Math.floor(points.length / 120) + 2));
for (let i = 0; i <= xTicks; i++) {
const tickIndex = Math.max(0, Math.min(points.length - 1, Math.round((lastIndex / xTicks) * i)));
const tickPoint = points[tickIndex];
if (!tickPoint) continue;
const x = scaleX(tickIndex);
const label = formatRacelogTrendAxisLabel(tickPoint.stamp);
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(label.date, x, h - padding.bottom + 18);
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = '9px Montserrat, sans-serif';
ctx.fillText(label.time, x, h - padding.bottom + 30);
}
if (showSpeed) {
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.save();
ctx.translate(12, padding.top + (chartH / 2));
ctx.rotate(-Math.PI / 2);
ctx.fillText('WPM', 0, 0);
ctx.restore();
}
if (showAccuracy) {
ctx.fillStyle = 'rgba(74,222,128,0.32)';
ctx.font = '10px Montserrat, sans-serif';
ctx.textAlign = 'center';
ctx.save();
ctx.translate(w - 8, padding.top + (chartH / 2));
ctx.rotate(Math.PI / 2);
ctx.fillText('Accuracy', 0, 0);
ctx.restore();
}
if (showAccuracy) {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.75;
ctx.setLineDash([5, 5]);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
points.forEach((point, index) => {
const x = scaleX(index);
const y = scaleAccY(useSmoothing ? point.trendAccuracy : point.accuracy);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
if (showSpeed) {
ctx.strokeStyle = '#f3a81b';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
points.forEach((point, index) => {
const x = scaleX(index);
const y = scaleSpeedY(useSmoothing ? point.trendWpm : point.wpm);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
if (!hoverMouse || chartW <= 0 || chartH <= 0) return;
const withinChart = hoverMouse.x >= padding.left
&& hoverMouse.x <= (w - padding.right)
&& hoverMouse.y >= padding.top
&& hoverMouse.y <= (h - padding.bottom);
if (!withinChart) return;
const hoverPoint = getNearestIndexedPoint(points, invertScaleX(hoverMouse.x));
if (!hoverPoint) return;
const hoverIndex = Math.max(0, points.indexOf(hoverPoint));
const hoverX = scaleX(hoverIndex);
const speedY = scaleSpeedY(useSmoothing ? hoverPoint.trendWpm : hoverPoint.wpm);
const accY = scaleAccY(useSmoothing ? hoverPoint.trendAccuracy : hoverPoint.accuracy);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(hoverX, padding.top);
ctx.lineTo(hoverX, padding.top + chartH);
ctx.stroke();
ctx.setLineDash([]);
if (showSpeed) {
ctx.beginPath();
ctx.arc(hoverX, speedY, 5, 0, Math.PI * 2);
ctx.fillStyle = '#f3a81b';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
}
if (showAccuracy) {
ctx.beginPath();
ctx.arc(hoverX, accY, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#4ade80';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
}
const tooltipLines = [
{ text: `Race #${hoverPoint.raceNum}`, color: '#ffffff' },
{ text: formatRacelogTrendTooltipTimestamp(hoverPoint.stamp), color: 'rgba(255,255,255,0.72)' },
...(showSpeed ? [{ text: `${hoverPoint.wpm} WPM`, color: '#f3a81b' }] : []),
...(showAccuracy ? [{ text: `${hoverPoint.accuracy.toFixed(2)}% Acc`, color: '#4ade80' }] : []),
{ text: `${hoverPoint.errors} Errors`, color: '#f87171' },
{ text: `${hoverPoint.typed.toLocaleString()} Length`, color: '#a6aac1' }
];
ctx.font = '12px Montserrat, sans-serif';
const lineHeight = 17;
const tooltipPaddingX = 10;
const tooltipPaddingY = 8;
const tooltipWidth = Math.max(...tooltipLines.map((line) => ctx.measureText(line.text).width)) + (tooltipPaddingX * 2);
const tooltipHeight = (tooltipLines.length * lineHeight) + (tooltipPaddingY * 2) - 4;
let tooltipX = hoverX + 14;
let tooltipY = Math.min(speedY, accY) - tooltipHeight - 14;
if (tooltipX + tooltipWidth > w - 8) tooltipX = hoverX - tooltipWidth - 14;
if (tooltipX < 8) tooltipX = 8;
if (tooltipY < 8) tooltipY = Math.max(speedY, accY) + 14;
if (tooltipY + tooltipHeight > h - 8) tooltipY = h - tooltipHeight - 8;
ctx.fillStyle = 'rgba(9, 14, 24, 0.95)';
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 8);
ctx.fill();
ctx.stroke();
tooltipLines.forEach((line, index) => {
ctx.fillStyle = line.color;
ctx.textAlign = 'left';
ctx.fillText(line.text, tooltipX + tooltipPaddingX, tooltipY + tooltipPaddingY + 11 + (index * lineHeight));
});
ctx.restore();
}
function closeRacelogTrendOverlay() {
document.querySelectorAll(`[${RACELOG_TREND_OVERLAY_ATTR}]`).forEach((el) => {
if (typeof el._estatsClose === 'function') {
el._estatsClose();
} else {
el.remove();
}
});
}
async function openRacelogTrendGraph(button) {
if (document.querySelector(`[${RACELOG_TREND_OVERLAY_ATTR}]`)) return;
const overlay = document.createElement('div');
overlay.setAttribute(RACELOG_TREND_OVERLAY_ATTR, '');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:10px;box-sizing:border-box;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#1d2030;border-radius:12px;padding:14px 18px;width:min(1180px, 95vw);max-height:min(90vh, 760px);overflow:auto;position:relative;border:1px solid rgba(255,255,255,0.1);box-sizing:border-box;';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.style.cssText = 'position:absolute;top:12px;right:16px;background:none;border:none;color:#a6aac1;font-size:24px;cursor:pointer;line-height:1;';
closeBtn.innerHTML = '×';
modal.appendChild(closeBtn);
const title = document.createElement('h4');
title.className = 'tss ttu tc-ts';
title.style.cssText = 'margin:0 0 4px;';
title.textContent = 'Racelog Trend Graph';
modal.appendChild(title);
const subtitle = document.createElement('div');
subtitle.className = 'tsxs';
subtitle.style.cssText = 'color:#a6aac1;margin-bottom:10px;';
subtitle.textContent = 'Speed and accuracy across your racelog history.';
modal.appendChild(subtitle);
const content = document.createElement('div');
content.style.cssText = 'min-height:420px;display:flex;align-items:center;justify-content:center;color:#a6aac1;';
content.textContent = 'Loading racelog trend...';
modal.appendChild(content);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const onEsc = (event) => {
if (event.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', onEsc);
}
};
document.addEventListener('keydown', onEsc);
const teardown = () => {
overlay.remove();
document.removeEventListener('keydown', onEsc);
};
overlay._estatsClose = teardown;
closeBtn.addEventListener('click', teardown);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) teardown();
});
if (button) {
button.disabled = true;
button.style.opacity = '0.75';
}
try {
const logs = await fetchAllDataForType('racelog');
const allPoints = buildRacelogTrendPoints(logs);
if (!allPoints.length) {
content.textContent = 'No racelog history was available to graph.';
return;
}
content.style.display = 'block';
content.style.alignItems = 'stretch';
content.style.justifyContent = 'flex-start';
content.style.minHeight = '0';
content.style.color = '';
const smoothingWindow = allPoints[0].smoothingWindow || 1;
let filteredPoints = allPoints;
const rangeState = { mode: 'all', startStamp: null, endStamp: null };
content.innerHTML = '';
const rangeBar = document.createElement('div');
rangeBar.style.cssText = 'display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;';
content.appendChild(rangeBar);
const presetGroup = document.createElement('div');
presetGroup.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;';
rangeBar.appendChild(presetGroup);
const customGroup = document.createElement('div');
customGroup.style.cssText = 'display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:6px;';
rangeBar.appendChild(customGroup);
const customStartInput = document.createElement('input');
customStartInput.type = 'datetime-local';
customStartInput.min = formatDatetimeLocalValue(allPoints[0].stamp);
customStartInput.max = formatDatetimeLocalValue(allPoints[allPoints.length - 1].stamp);
customStartInput.value = customStartInput.min;
customStartInput.style.cssText = 'background:#151928;color:#d8dcf2;border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:5px 7px;font-size:10px;';
customGroup.appendChild(customStartInput);
const customEndInput = document.createElement('input');
customEndInput.type = 'datetime-local';
customEndInput.min = customStartInput.min;
customEndInput.max = customStartInput.max;
customEndInput.value = customEndInput.max;
customEndInput.style.cssText = customStartInput.style.cssText;
customGroup.appendChild(customEndInput);
const customApplyBtn = document.createElement('button');
customApplyBtn.type = 'button';
customApplyBtn.className = 'btn btn--primary';
customApplyBtn.style.cssText = 'padding:5px 10px;font-size:10px;';
customApplyBtn.textContent = 'Apply';
customGroup.appendChild(customApplyBtn);
const statsRow = document.createElement('div');
statsRow.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:6px;margin-bottom:8px;';
statsRow.innerHTML = [
`<div class="well tac" data-estats-racelog-races style="padding:8px 6px;"><div class="tsxs ttu tc-ts" style="margin-bottom:3px;">Races Graphed</div><div class="tss" style="font-weight:700;color:#d8dcf2;">—</div></div>`,
`<div class="well tac" data-estats-racelog-avg-wpm style="padding:8px 6px;"><div class="tsxs ttu tc-ts" style="margin-bottom:3px;">Avg WPM</div><div class="tss" style="font-weight:700;color:#f3a81b;">—</div></div>`,
`<div class="well tac" data-estats-racelog-avg-acc style="padding:8px 6px;"><div class="tsxs ttu tc-ts" style="margin-bottom:3px;">Avg Accuracy</div><div class="tss" style="font-weight:700;color:#4ade80;">—</div></div>`,
`<div class="well tac" data-estats-racelog-date-range style="padding:8px 6px;"><div class="tsxs ttu tc-ts" style="margin-bottom:3px;">Date Range</div><div class="tss" style="font-weight:700;color:#a6aac1;">—</div></div>`,
`<div class="well tac" data-estats-racelog-line-mode style="padding:8px 6px;">
<div class="tsxs ttu tc-ts" style="margin-bottom:3px;">Line Mode</div>
<div class="tss" style="font-weight:700;color:#a6aac1;">Smoothed (${smoothingWindow}-Race Avg)</div>
</div>`
].join('');
content.appendChild(statsRow);
const emptyState = document.createElement('div');
emptyState.className = 'tsxs';
emptyState.style.cssText = 'display:none;color:#a6aac1;text-align:center;padding:18px 0 8px;';
emptyState.textContent = 'No races matched that date range.';
content.appendChild(emptyState);
const canvas = document.createElement('canvas');
canvas.width = 1120;
canvas.height = 290;
canvas.style.cssText = 'width:100%;height:auto;border-radius:8px;cursor:crosshair;';
content.appendChild(canvas);
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-top:6px;';
const toggles = document.createElement('div');
toggles.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;';
controls.appendChild(toggles);
const key = document.createElement('div');
key.style.cssText = 'display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:rgba(255,255,255,0.42);align-items:center;';
key.innerHTML = `
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:20px;height:2px;background:#f3a81b;"></span> Speed Trend</span>
<span style="display:flex;align-items:center;gap:6px;"><span style="display:inline-block;width:20px;height:0;border-top:2px dashed #4ade80;"></span> Accuracy Trend</span>
`;
controls.appendChild(key);
content.appendChild(controls);
const chartState = { showSpeed: true, showAccuracy: true, useSmoothing: true, hoverMouse: null };
const lineModeWellValue = statsRow.querySelector('[data-estats-racelog-line-mode] .tss');
const racesWellValue = statsRow.querySelector('[data-estats-racelog-races] .tss');
const avgWpmWellValue = statsRow.querySelector('[data-estats-racelog-avg-wpm] .tss');
const avgAccWellValue = statsRow.querySelector('[data-estats-racelog-avg-acc] .tss');
const dateRangeWellValue = statsRow.querySelector('[data-estats-racelog-date-range] .tss');
let rafId = 0;
const scheduleRedraw = () => {
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
drawRacelogTrendChart(canvas, filteredPoints, chartState);
});
};
const syncLineModeLabel = () => {
if (!lineModeWellValue) return;
lineModeWellValue.textContent = chartState.useSmoothing
? `Smoothed (${smoothingWindow}-Race Avg)`
: 'Raw Race Values';
};
const updateSummary = () => {
if (!filteredPoints.length) {
racesWellValue.textContent = '0';
avgWpmWellValue.textContent = '—';
avgAccWellValue.textContent = '—';
dateRangeWellValue.textContent = 'No races';
canvas.style.display = 'none';
controls.style.display = 'none';
emptyState.style.display = 'block';
return;
}
const avgWpm = Math.round(filteredPoints.reduce((sum, point) => sum + point.wpm, 0) / filteredPoints.length);
const avgAcc = Math.round((filteredPoints.reduce((sum, point) => sum + point.accuracy, 0) / filteredPoints.length) * 100) / 100;
const firstStamp = filteredPoints[0].stamp;
const lastStamp = filteredPoints[filteredPoints.length - 1].stamp;
racesWellValue.textContent = filteredPoints.length.toLocaleString();
avgWpmWellValue.textContent = String(avgWpm);
avgAccWellValue.textContent = `${avgAcc.toFixed(2)}%`;
dateRangeWellValue.textContent = `${formatRacelogTrendLabel(firstStamp)} - ${formatRacelogTrendLabel(lastStamp)}`;
canvas.style.display = '';
controls.style.display = '';
emptyState.style.display = 'none';
};
const presetButtons = new Map();
const syncPresetButtons = () => {
presetButtons.forEach((btn, key) => {
const active = rangeState.mode === key;
btn.style.opacity = active ? '1' : '0.5';
btn.style.background = active ? '#697187' : 'rgba(255,255,255,0.08)';
});
};
const applyRangeState = () => {
filteredPoints = filterRacelogTrendPoints(allPoints, rangeState);
chartState.hoverMouse = null;
updateSummary();
scheduleRedraw();
syncPresetButtons();
};
const makePresetButton = (label, mode) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.style.cssText = 'background:rgba(255,255,255,0.08);color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:10px;font-weight:700;cursor:pointer;letter-spacing:0.5px;text-transform:uppercase;opacity:0.5;transition:opacity 0.2s, background 0.2s;';
btn.textContent = label;
btn.addEventListener('click', () => {
rangeState.mode = mode;
rangeState.startStamp = null;
rangeState.endStamp = null;
applyRangeState();
});
presetButtons.set(mode, btn);
presetGroup.appendChild(btn);
};
const makeToggle = (label, color, keyName) => {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.style.cssText = `background:${color};color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:10px;font-weight:700;cursor:pointer;letter-spacing:0.5px;text-transform:uppercase;opacity:1;transition:opacity 0.2s;`;
toggle.textContent = label;
toggle.addEventListener('click', () => {
chartState[keyName] = !chartState[keyName];
toggle.style.opacity = chartState[keyName] ? '1' : '0.3';
scheduleRedraw();
});
toggles.appendChild(toggle);
};
makePresetButton('24H', '24h');
makePresetButton('7D', '7d');
makePresetButton('30D', '30d');
makePresetButton('All', 'all');
makeToggle('Speed', '#f3a81b', 'showSpeed');
makeToggle('Accuracy', '#4ade80', 'showAccuracy');
const modeToggle = document.createElement('button');
modeToggle.type = 'button';
modeToggle.style.cssText = 'background:#697187;color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:10px;font-weight:700;cursor:pointer;letter-spacing:0.5px;text-transform:uppercase;opacity:1;transition:opacity 0.2s;';
const syncModeToggle = () => {
modeToggle.textContent = chartState.useSmoothing ? 'Smoothed' : 'Raw';
syncLineModeLabel();
};
modeToggle.addEventListener('click', () => {
chartState.useSmoothing = !chartState.useSmoothing;
syncModeToggle();
scheduleRedraw();
});
toggles.appendChild(modeToggle);
syncModeToggle();
customApplyBtn.addEventListener('click', () => {
const startStamp = parseDatetimeLocalValue(customStartInput.value);
const endStamp = parseDatetimeLocalValue(customEndInput.value);
if (startStamp != null && endStamp != null && startStamp > endStamp) {
return;
}
rangeState.mode = 'custom';
rangeState.startStamp = startStamp;
rangeState.endStamp = endStamp;
applyRangeState();
});
const syncHoverMouse = (event) => {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
chartState.hoverMouse = {
x: ((event.clientX - rect.left) / rect.width) * (canvas._origW || canvas.width),
y: ((event.clientY - rect.top) / rect.height) * (canvas._origH || canvas.height)
};
scheduleRedraw();
};
canvas.addEventListener('mousemove', syncHoverMouse);
canvas.addEventListener('mouseleave', () => {
chartState.hoverMouse = null;
scheduleRedraw();
});
applyRangeState();
} catch (error) {
console.error(LOG_PREFIX, 'Racelog trend graph error:', error);
content.textContent = 'Failed to load racelog trend data.';
} finally {
if (button) {
button.disabled = false;
button.style.opacity = '1';
}
}
}
function injectRacelogTrendGraphButton() {
const path = normalizePath(window.location.pathname);
if (!path.startsWith('/racelog')) return;
const returnBtn = document.querySelector('a.btn.btn--primary[href="/stats"]');
if (!returnBtn) return;
const actionWell = returnBtn.parentElement;
if (!actionWell || actionWell.querySelector(`[${RACELOG_TREND_BTN_ATTR}]`)) return;
rememberOriginalStyle(actionWell);
actionWell.style.display = 'flex';
actionWell.style.alignItems = 'center';
actionWell.style.justifyContent = 'flex-end';
actionWell.style.flexWrap = 'wrap';
actionWell.style.gap = '8px';
const trendBtn = document.createElement('button');
trendBtn.type = 'button';
trendBtn.className = 'btn btn--primary';
trendBtn.setAttribute(RACELOG_TREND_BTN_ATTR, '');
trendBtn.innerHTML = `<span style="display:flex;align-items:center;gap:6px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 17 9 11 13 15 21 7"></polyline><polyline points="14 7 21 7 21 14"></polyline></svg><span>Trend Graph</span></span>`;
trendBtn.addEventListener('click', () => {
void openRacelogTrendGraph(trendBtn);
});
actionWell.insertBefore(trendBtn, returnBtn);
}
function getActiveRacelogPageNumber() {
const activeBtn = document.querySelector('.btn--page.is-active');
const parsed = parseInt(activeBtn?.textContent || '1', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : 0;
}
function parseRacelogResultsRowMetrics(row) {
if (!row) return null;
const stats = Array.from(row.querySelectorAll('.list-item')).map((node) => node.textContent.replace(/\s+/g, ' ').trim());
let wpm = null;
let accuracy = null;
let secs = null;
stats.forEach((text) => {
const wpmMatch = text.match(/([0-9]+(?:\.[0-9]+)?)\s*WPM/i);
if (wpmMatch) {
wpm = Number(wpmMatch[1]);
}
if (!/^n\/a/i.test(text)) {
const accMatch = text.match(/([0-9]+(?:\.[0-9]+)?)\s*%?\s*Acc/i);
if (accMatch) {
accuracy = Number(accMatch[1]);
}
}
const secsMatch = text.match(/([0-9]+(?:\.[0-9]+)?)\s*secs/i);
if (secsMatch) {
secs = Number(secsMatch[1]);
}
});
let placed = null;
if (row.querySelector('.raceResults-placement-winner')) {
placed = 1;
} else {
const placeText = row.querySelector('.raceResults-placement-other')?.textContent || '';
const placeMatch = placeText.match(/(\d+)/);
if (placeMatch) {
placed = Number(placeMatch[1]);
}
}
return {
title: row.querySelector('.player-name--container[title]')?.getAttribute('title') || '',
wpm,
accuracy,
secs,
placed
};
}
function computeRaceResultPoints(wpm, accuracyPercent) {
if (!Number.isFinite(wpm) || !Number.isFinite(accuracyPercent)) return null;
return Math.max(0, Math.round((accuracyPercent / 100) * (100 + (wpm / 2))));
}
function findMatchingRacelogSelfLog(logs, metrics) {
if (!Array.isArray(logs) || !metrics) return null;
return logs.find((log) => {
const typed = Number(log?.typed);
const secs = Number(log?.secs);
const errs = Number(log?.errs);
const placed = Number(log?.placed);
if (Number.isFinite(metrics.placed) && Number.isFinite(placed) && placed !== metrics.placed) {
return false;
}
const logWpm = Number.isFinite(typed) && Number.isFinite(secs) && secs > 0
? Math.round((typed / 5) / (secs / 60))
: null;
const logAcc = Number.isFinite(typed) && typed > 0 && Number.isFinite(errs)
? Math.round((((typed - errs) * 100) / typed) * 100) / 100
: null;
const wpmMatches = metrics.wpm == null || logWpm === metrics.wpm;
const secsMatches = metrics.secs == null || (Number.isFinite(secs) && Math.abs(secs - metrics.secs) <= 0.051);
const accMatches = metrics.accuracy == null || (logAcc != null && Math.abs(logAcc - metrics.accuracy) <= 0.02);
return wpmMatches && secsMatches && accMatches;
}) || null;
}
function getRacelogResultsModalSignature(results) {
const selfRow = results?.querySelector('.gridTable-row.is-self') || results?.querySelector('.gridTable-row');
const metrics = parseRacelogResultsRowMetrics(selfRow);
if (!metrics) return '';
return [
metrics.title,
metrics.wpm ?? '',
metrics.accuracy ?? '',
metrics.secs ?? '',
metrics.placed ?? ''
].join('|');
}
function appendRacelogModalSummary(row, parts) {
if (!row || !Array.isArray(parts) || parts.length === 0) return;
const mainCell = row.querySelectorAll('.gridTable-cell')[1] || row.querySelector('.gridTable-cell');
const splitReverse = mainCell?.querySelector('.split.split--flag.split--reverse');
const statsList = splitReverse?.querySelector('.list.list--inline.list--flag');
if (!mainCell || !splitReverse || !statsList) return;
applyRaceResultStatsListLayout(mainCell, statsList);
const expandedRow = document.createElement('div');
expandedRow.className = 'tsxs db';
expandedRow.setAttribute(RACELOG_MODAL_ITEM_ATTR, '');
expandedRow.style.cssText = 'grid-column:1 / -1;padding:2px 16px 8px 0;box-sizing:border-box;';
const expandedList = statsList.cloneNode(true);
expandedList.style.cssText = 'display:flex;flex-wrap:wrap;justify-content:flex-end;margin-top:2px;';
parts.forEach((partHtml) => {
const item = document.createElement('div');
item.className = 'list-item';
item.innerHTML = partHtml;
expandedList.appendChild(item);
});
expandedRow.appendChild(expandedList);
row.appendChild(expandedRow);
if (!splitReverse.hasAttribute(RACELOG_MODAL_SPLIT_DISPLAY_ATTR)) {
splitReverse.setAttribute(RACELOG_MODAL_SPLIT_DISPLAY_ATTR, splitReverse.style.getPropertyValue('display') || '');
}
splitReverse.style.setProperty('display', 'none');
const rowRect = row.getBoundingClientRect();
const expandedRect = expandedRow.getBoundingClientRect();
const overflow = Math.max(0, expandedRect.bottom - rowRect.bottom);
if (!row.hasAttribute(RACELOG_MODAL_ROW_HEIGHT_ATTR)) {
row.setAttribute(RACELOG_MODAL_ROW_HEIGHT_ATTR, row.style.getPropertyValue('height') || '');
}
if (!row.hasAttribute(RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR)) {
row.setAttribute(RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR, row.style.getPropertyValue('min-height') || '');
}
if (overflow > 0) {
const newHeight = Math.ceil(rowRect.height + overflow + 2);
row.style.setProperty('height', `${newHeight}px`, 'important');
row.style.setProperty('min-height', `${newHeight}px`, 'important');
}
}
function restoreRacelogModalRowHeights(results) {
if (!results) return;
results.querySelectorAll('.gridTable-row').forEach((row) => {
if (row.hasAttribute(RACELOG_MODAL_ROW_HEIGHT_ATTR)) {
const originalHeight = row.getAttribute(RACELOG_MODAL_ROW_HEIGHT_ATTR) || '';
if (originalHeight) {
row.style.setProperty('height', originalHeight);
} else {
row.style.removeProperty('height');
}
row.removeAttribute(RACELOG_MODAL_ROW_HEIGHT_ATTR);
}
if (row.hasAttribute(RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR)) {
const originalMinHeight = row.getAttribute(RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR) || '';
if (originalMinHeight) {
row.style.setProperty('min-height', originalMinHeight);
} else {
row.style.removeProperty('min-height');
}
row.removeAttribute(RACELOG_MODAL_ROW_MIN_HEIGHT_ATTR);
}
});
results.querySelectorAll(`.split.split--flag.split--reverse[${RACELOG_MODAL_SPLIT_DISPLAY_ATTR}]`).forEach((splitReverse) => {
const originalDisplay = splitReverse.getAttribute(RACELOG_MODAL_SPLIT_DISPLAY_ATTR) || '';
if (originalDisplay) {
splitReverse.style.setProperty('display', originalDisplay);
} else {
splitReverse.style.removeProperty('display');
}
splitReverse.removeAttribute(RACELOG_MODAL_SPLIT_DISPLAY_ATTR);
});
}
async function enhanceOpenRacelogResultsModal() {
const results = document.querySelector('.modal--raceResults.is-active .raceResults');
if (!results) return;
const signature = getRacelogResultsModalSignature(results);
if (!signature) return;
if (results.getAttribute(RACELOG_MODAL_ATTR) === signature) return;
restoreRacelogModalRowHeights(results);
results.querySelectorAll(`[${RACELOG_MODAL_ITEM_ATTR}]`).forEach((el) => el.remove());
const selfMetrics = parseRacelogResultsRowMetrics(results.querySelector('.gridTable-row.is-self'));
let selfLog = null;
try {
const logs = await fetchRacelogPage(getActiveRacelogPageNumber());
selfLog = findMatchingRacelogSelfLog(logs, selfMetrics);
} catch (error) {
console.error(LOG_PREFIX, 'Racelog modal fetch error:', error);
}
results.querySelectorAll('.gridTable-row').forEach((row) => {
const metrics = parseRacelogResultsRowMetrics(row);
const summaryParts = [];
const points = computeRaceResultPoints(metrics?.wpm, metrics?.accuracy);
if (points != null) {
summaryParts.push(`<span>${escapeHtml(String(points))}</span> <span class="tc-ts">Points</span>`);
}
if (row.classList.contains('is-self') && selfLog) {
const errors = Number(selfLog.errs);
const nitros = Number(selfLog.nitros);
const typed = Number(selfLog.typed);
if (Number.isFinite(errors)) {
summaryParts.push(`<span>${escapeHtml(String(errors))}</span> <span class="tc-ts">Errors</span>`);
}
if (Number.isFinite(nitros)) {
summaryParts.push(`<span>${escapeHtml(String(nitros))}</span> <span class="tc-ts">Nitro${nitros === 1 ? '' : 's'}</span>`);
}
if (Number.isFinite(typed) && typed > 0) {
summaryParts.push(`<span>${escapeHtml(String(typed))}</span> <span class="tc-ts">Length</span>`);
}
}
appendRacelogModalSummary(row, summaryParts);
});
results.setAttribute(RACELOG_MODAL_ATTR, signature);
}
// ── Shared helpers ──
function calcWPM(r) { return r.secs > 0 ? Math.round(((r.typed || 0) / 5) / (r.secs / 60)) : 0; }
function calcAcc(r) { return r.typed > 0 ? ((r.typed - (r.errs || 0)) / r.typed) * 100 : 0; }
function ordinal(n) {
if (n >= 11 && n <= 13) return 'th';
switch (n % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; }
}
function fmtDate(stamp) {
const d = new Date(stamp * 1000);
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
const yr = String(d.getFullYear()).slice(2);
let hr = d.getHours();
const ap = hr >= 12 ? 'pm' : 'am';
hr = hr % 12 || 12;
const mn = String(d.getMinutes()).padStart(2, '0');
return `${mo}/${da}/${yr} ${hr}:${mn} <span class="tsxs tc-ts ttu">${ap}</span>`;
}
function fmtDateShort(stamp) {
const d = new Date(stamp * 1000);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${String(d.getFullYear()).slice(2)}`;
}
function placeHTML(place) {
const isFirst = place === 1;
const pc = isFirst ? 'tc-lemon' : '';
const sc = isFirst ? 'tc-lemon' : 'tc-ts';
return `<span class="${pc}">${place}</span><span class="tsxs ttu ${sc}">${ordinal(place)}</span>`;
}
// ── Detect table type from headers ──
function detectTableType(table) {
const headers = Array.from(table.querySelectorAll('thead th.table-cell')).map(h => h.textContent.trim().replace(/[▲▼]/g, '').trim());
if (table.classList.contains('table--selectable')) return 'racelog';
if (headers[0] === 'Month') return 'monthly';
if (headers.includes('Speed')) return 'topSpeed';
if (headers.includes('Races')) return 'daily';
return 'unknown';
}
function isElementVisible(el) {
return !!(el && (el.offsetParent !== null || el.getClientRects().length > 0));
}
function restoreVisibleNativePagination() {
document.querySelectorAll('.has-btn.has-btn--s').forEach(el => {
if (!el.hasAttribute('data-estats-sort-pagination')) {
el.style.display = '';
}
});
}
function cleanupDetachedSortPagination() {
let removedAny = false;
document.querySelectorAll('[data-estats-sort-pagination]').forEach(wrapper => {
const ownerId = wrapper.getAttribute('data-estats-sort-owner-id');
if (!ownerId) return;
const ownerTable = document.querySelector(`table.table--striped[data-estats-sort-owner-id="${ownerId}"]`);
const ownerActive = !!(ownerTable && ownerTable.hasAttribute('data-estats-sort-active'));
if (!ownerTable || !isElementVisible(ownerTable) || !ownerActive) {
wrapper.remove();
removedAny = true;
}
});
if (removedAny && !document.querySelector('[data-estats-sort-pagination]')) {
restoreVisibleNativePagination();
}
}
function scheduleSortPaginationCleanup() {
if (_deferredSortCleanupTimer) {
clearTimeout(_deferredSortCleanupTimer);
}
_deferredSortCleanupTimer = setTimeout(() => {
_deferredSortCleanupTimer = null;
cleanupDetachedSortPagination();
}, 50);
}
function getEnhancedRacelogWorkKey(path) {
const racelogTable = document.querySelector('.table--striped.table--selectable');
const rows = racelogTable ? Array.from(racelogTable.querySelectorAll('tbody .table-row')) : [];
const pageNum = racelogTable ? getActiveRacelogPageNumber() : 0;
const pageKey = String(pageNum);
const activeModalResults = document.querySelector('.modal--raceResults.is-active .raceResults');
const modalSignature = activeModalResults ? getRacelogResultsModalSignature(activeModalResults) : '';
const mainHydrated = !!(racelogTable
&& racelogTable.getAttribute(RACELOG_PAGE_ATTR) === pageKey
&& rows.length > 0
&& rows.every((row) => row.getAttribute(RACELOG_ROW_PAGE_ATTR) === pageKey && row.querySelectorAll('td[data-estats-injected]').length === 3));
const sortable = !!(racelogTable && racelogTable.hasAttribute('data-estats-sortable'));
const sortPaginationCount = document.querySelectorAll('[data-estats-sort-pagination]').length;
return JSON.stringify({
path,
pageKey,
rowCount: rows.length,
mainHydrated,
sortable,
modalSignature,
modalActive: !!activeModalResults,
stripedTables: document.querySelectorAll('.table--striped').length,
sortPaginationCount
});
}
function resetAllRacelogTabSortState() {
let removedAny = false;
Array.from(_sortableTables).forEach(table => {
if (!table || !table.isConnected) {
_sortableTables.delete(table);
return;
}
if (!table.hasAttribute('data-estats-sort-active')) return;
const resetFn = table.__ntEstatsResetSort;
if (typeof resetFn === 'function') {
resetFn();
} else {
table.removeAttribute('data-estats-sort-active');
table.querySelectorAll('[data-estats-sort-arrow]').forEach(el => el.remove());
}
removedAny = true;
});
if (removedAny) {
document.querySelectorAll('[data-estats-sort-pagination]').forEach(el => el.remove());
restoreVisibleNativePagination();
}
}
function bindRacelogTabReset() {
const tabsRoot = document.querySelector('.tabs.tabs--a.tabs--fourUp');
if (!tabsRoot || tabsRoot.hasAttribute('data-estats-tab-reset-bound')) return;
tabsRoot.setAttribute('data-estats-tab-reset-bound', '');
tabsRoot.addEventListener('click', (event) => {
const tabBtn = event.target.closest('button.tab');
if (!tabBtn || tabBtn.classList.contains('is-active')) return;
resetAllRacelogTabSortState();
}, true);
}
// ── Build row for daily summary (pre-aggregated from API type=lastdays) ──
// Fields: stamp, races, placed (total), secs, nitros, errs, typed, reward.money, reward.exp
function buildDailyRow(g) {
const tr = document.createElement('tr');
tr.className = 'table-row';
const avgPlace = g.races > 0 ? Math.round(g.placed / g.races) : 0;
tr.innerHTML = `
<td class="table-cell"><span>${fmtDateShort(g.stamp)}</span></td>
<td class="table-cell">${(g.races || 0).toLocaleString()}</td>
<td class="table-cell">${placeHTML(avgPlace)}</td>
<td class="table-cell">${(g.secs || 0).toLocaleString()}<span class="tsxxs tc-ts mlxxs tc-ts ttu"> secs</span></td>
<td class="table-cell">${(g.nitros || 0).toLocaleString()}</td>
<td class="table-cell"><span class="tc-emerald as-nitro-cash--prefix"><span class="as-nitro-cash--prefix">$${(g.reward?.money || 0).toLocaleString()}</span></span></td>
`;
return tr;
}
// ── Build row for monthly summary (pre-aggregated from API type=bymonth) ──
// Fields: month, year, races, placed (total), secs, nitros, errs, typed, reward.money, reward.exp
function buildMonthlyRow(g) {
const tr = document.createElement('tr');
tr.className = 'table-row';
const avgPlace = g.races > 0 ? Math.round(g.placed / g.races) : 0;
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const monthStr = `${months[(g.month || 1) - 1]} '${String(g.year || 0).slice(2)}`;
tr.innerHTML = `
<td class="table-cell"><span>${monthStr}</span></td>
<td class="table-cell">${(g.races || 0).toLocaleString()}</td>
<td class="table-cell">${placeHTML(avgPlace)}</td>
<td class="table-cell">${(g.secs || 0).toLocaleString()}<span class="tsxxs tc-ts mlxxs tc-ts ttu"> secs</span></td>
<td class="table-cell">${(g.nitros || 0).toLocaleString()}</td>
<td class="table-cell"><span class="tc-emerald as-nitro-cash--prefix"><span class="as-nitro-cash--prefix">$${(g.reward?.money || 0).toLocaleString()}</span></span></td>
`;
return tr;
}
// ── Build row for top speeds (from API type=topspeeds) ──
// Fields: speed, stamp, place, typed, secs, errs (no nitros or reward)
function buildTopSpeedRow(r) {
const tr = document.createElement('tr');
tr.className = 'table-row';
const acc = r.typed > 0 ? ((r.typed - (r.errs || 0)) / r.typed) * 100 : 0;
const accStr = acc % 1 === 0 ? String(Math.round(acc)) : acc.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
tr.innerHTML = `
<td class="table-cell"><span>${fmtDate(r.stamp || 0)}</span></td>
<td class="table-cell">${placeHTML(r.place || 0)}</td>
<td class="table-cell">${r.secs || 0}<span class="tsxxs tc-ts mlxxs tc-ts ttu"> secs</span></td>
<td class="table-cell">${accStr}<span class="tsxs tc-ts">%</span></td>
<td class="table-cell">${r.speed || 0}<span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell" data-estats-injected="" style="color:${(r.errs||0) > 0 ? '#d62f3a' : '#a6aac1'};">${r.errs || 0}</td>
<td class="table-cell" data-estats-injected="" style="color:#a6aac1;">${(r.typed||0) > 0 ? r.typed.toLocaleString() : '\u2014'}</td>
`;
return tr;
}
// ── Build row for main racelog ──
function buildRacelogRow(r) {
const tr = document.createElement('tr');
tr.className = 'table-row';
const wpm = calcWPM(r);
const acc = calcAcc(r);
const accStr = acc % 1 === 0 ? String(Math.round(acc)) : acc.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
const errors = r.errs ?? 0;
const length = r.typed ?? 0;
const money = r.reward?.money || 0;
const xp = r.reward?.exp || 0;
const nitroUsed = (r.nitros ?? 0) > 0;
tr.innerHTML = `
<td class="table-cell"><span class="tsxs tc-ts ttu">#</span>${(r._raceNum || 0).toLocaleString()}</td>
<td class="table-cell">${fmtDate(r.stamp || 0)}</td>
<td class="table-cell">${placeHTML(r.placed || 0)} / 5</td>
<td class="table-cell">${wpm}<span class="tsxxs tc-ts mlxxs">WPM</span></td>
<td class="table-cell">${accStr}<span class="tsxs tc-ts">%</span></td>
<td class="table-cell">${r.secs || 0}<span class="tsxxs tc-ts mlxxs tc-ts ttu"> secs</span></td>
<td class="table-cell"><span class="tc-emerald as-nitro-cash--prefix"><span class="as-nitro-cash--prefix">$${money.toLocaleString()}</span></span></td>
<td class="table-cell">${xp.toLocaleString()}</td>
<td class="table-cell" data-estats-injected="" style="color:${errors > 0 ? '#d62f3a' : '#a6aac1'};">${errors}</td>
<td class="table-cell" data-estats-injected="" style="color:#a6aac1;">${length > 0 ? length.toLocaleString() : '\u2014'}</td>
<td class="table-cell" data-estats-injected="" style="color:${nitroUsed ? '#f3a81b' : '#a6aac1'};">${nitroUsed ? 'Yes' : 'No'}</td>
`;
return tr;
}
// ── Sort key configs per table type (matching actual API field names) ──
const SORT_KEYS = {
daily: {
0: g => g.stamp || 0, // Date
1: g => g.races || 0, // Races
2: g => g.races > 0 ? g.placed / g.races : 0, // Avg Place
3: g => g.secs || 0, // Total Time
4: g => g.nitros || 0, // Nitros Used
5: g => g.reward?.money || 0 // Money Won
},
monthly: {
0: g => (g.year || 0) * 12 + (g.month || 0), // Month (chronological)
1: g => g.races || 0,
2: g => g.races > 0 ? g.placed / g.races : 0,
3: g => g.secs || 0,
4: g => g.nitros || 0,
5: g => g.reward?.money || 0
},
topSpeed: {
0: r => r.stamp || 0, // Date
1: r => r.place || 0, // Place
2: r => r.secs || 0, // Time
3: r => calcAcc(r), // Accuracy
4: r => r.speed || 0, // Speed (pre-computed WPM)
5: r => r.errs || 0, // Errors (injected)
6: r => r.typed || 0 // Length (injected)
},
racelog: {
0: r => r._raceNum || 0,
1: r => r.stamp || 0,
2: r => r.placed || 0,
3: r => calcWPM(r),
4: r => calcAcc(r),
5: r => r.secs || 0,
6: r => r.reward?.money || 0,
7: r => r.reward?.exp || 0,
8: r => r.errs || 0,
9: r => r.typed || 0,
10: r => (r.nitros||0)>0?1:0
}
};
const ROW_BUILDERS = { daily: buildDailyRow, monthly: buildMonthlyRow, topSpeed: buildTopSpeedRow, racelog: buildRacelogRow };
// ── Universal cross-page sorting for any table type ──
function setupCrossPageSorting(table, tableType) {
if (table.hasAttribute('data-estats-sortable')) return;
table.setAttribute('data-estats-sortable', '');
_sortableTables.add(table);
if (!table.hasAttribute('data-estats-sort-owner-id')) {
_sortOwnerCounter += 1;
table.setAttribute('data-estats-sort-owner-id', `estats-sort-${_sortOwnerCounter}`);
}
const thead = table.querySelector('thead .table-row');
const tbody = table.querySelector('tbody');
if (!thead || !tbody) return;
const headers = thead.querySelectorAll('th.table-cell');
let currentSort = { col: -1, asc: true };
let fetchedData = null;
let isFetching = false;
let sortedPage = 0;
const originalTbodyHTML = tbody.innerHTML;
const sortOwnerId = table.getAttribute('data-estats-sort-owner-id');
// Find ALL NT pagination elements near this table, walking up the DOM
const getNtPagination = () => {
let parent = table.parentElement;
for (let i = 0; i < 5 && parent; i++) {
const found = Array.from(parent.querySelectorAll('.has-btn.has-btn--s'))
.filter(el => !el.hasAttribute('data-estats-sort-pagination'));
if (found.length > 0) return found;
parent = parent.parentElement;
}
return [];
};
const sortKeys = SORT_KEYS[tableType] || {};
const buildRow = ROW_BUILDERS[tableType];
const colCount = headers.length;
const countLabel = tableType === 'racelog' ? 'races' : (tableType === 'daily' ? 'days' : (tableType === 'monthly' ? 'months' : 'races'));
function renderPage(sorted, page) {
tbody.innerHTML = '';
const start = page * PER_PAGE;
sorted.slice(start, start + PER_PAGE).forEach(r => tbody.appendChild(buildRow(r)));
sortedPage = page;
}
function buildPagination(sorted) {
// Remove previous sorted pagination (may be in NT's pagination parent, not table parent)
const ntPags = getNtPagination();
const searchRoot = (ntPags.length > 0 ? ntPags[0].parentElement : null) || table.parentElement;
const prev = searchRoot.querySelector('[data-estats-sort-pagination]');
if (prev) prev.remove();
const totalPages = Math.ceil(sorted.length / PER_PAGE);
if (totalPages <= 1) return;
const wrapper = document.createElement('div');
wrapper.setAttribute('data-estats-sort-pagination', '');
wrapper.setAttribute('data-estats-sort-owner-id', sortOwnerId);
wrapper.className = 'has-btn has-btn--s has-btn--wrap';
wrapper.style.cssText = 'margin-top:12px;display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:4px;';
function addBtn(label, pg, isActive, isOutline) {
const btn = document.createElement('button');
btn.className = isOutline ? 'btn btn--outline' : ('btn btn--page' + (isActive ? ' is-active' : ''));
btn.textContent = label;
btn.style.cursor = 'pointer';
if (!isActive) btn.addEventListener('click', () => { renderPage(sorted, pg); buildPagination(sorted); });
return btn;
}
wrapper.appendChild(addBtn('First', 0, false, false));
const pagesDiv = document.createElement('div');
pagesDiv.className = 'has-btn has-btn--xs has-btn--wrap mrxs';
let startP = Math.max(0, sortedPage - 4), endP = Math.min(totalPages, startP + 10);
if (endP - startP < 10) startP = Math.max(0, endP - 10);
for (let p = startP; p < endP; p++) pagesDiv.appendChild(addBtn(String(p + 1), p, p === sortedPage, false));
wrapper.appendChild(pagesDiv);
wrapper.appendChild(addBtn('Last', totalPages - 1, false, false));
const resetBtn = document.createElement('button');
resetBtn.className = 'btn btn--page';
resetBtn.textContent = 'Reset';
resetBtn.style.cssText = 'margin-left:8px;cursor:pointer;color:#d62f3a;';
resetBtn.addEventListener('click', resetSort);
wrapper.appendChild(resetBtn);
const ct = document.createElement('span');
ct.className = 'tsxs tc-ts';
ct.style.cssText = 'margin-left:8px;';
ct.textContent = `${sorted.length.toLocaleString()} ${countLabel}`;
wrapper.appendChild(ct);
// Insert in the same location as NT's native pagination
const ntInsertRef = getNtPagination();
if (ntInsertRef.length > 0) {
ntInsertRef[0].parentElement.insertBefore(wrapper, ntInsertRef[0]);
} else {
table.parentElement.appendChild(wrapper);
}
}
function resetSort() {
currentSort = { col: -1, asc: true };
tbody.innerHTML = originalTbodyHTML;
table.removeAttribute('data-estats-sort-active');
headers.forEach(h => { const a = h.querySelector('[data-estats-sort-arrow]'); if (a) a.remove(); });
// Remove our pagination from wherever it was inserted
const searchRoot2 = (getNtPagination()[0]?.parentElement) || table.parentElement;
const sp = searchRoot2.querySelector('[data-estats-sort-pagination]') ||
document.querySelector('[data-estats-sort-pagination]');
if (sp) sp.remove();
restoreVisibleNativePagination();
}
table.__ntEstatsResetSort = resetSort;
headers.forEach((th, colIdx) => {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
th.style.whiteSpace = 'nowrap';
th.title = 'Click to sort';
th.addEventListener('click', async () => {
if (isFetching) return;
const asc = (currentSort.col === colIdx) ? !currentSort.asc : (colIdx === 0);
currentSort = { col: colIdx, asc };
headers.forEach((h, i) => {
const existing = h.querySelector('[data-estats-sort-arrow]');
if (existing) existing.remove();
if (i === colIdx) {
const arrow = document.createElement('span');
arrow.setAttribute('data-estats-sort-arrow', '');
arrow.style.cssText = 'margin-left:4px;font-size:10px;';
arrow.textContent = asc ? '▲' : '▼';
h.appendChild(arrow);
}
});
if (!fetchedData) {
tbody.innerHTML = `<tr class="table-row"><td class="table-cell" colspan="${colCount}" style="text-align:center;padding:20px;color:#a6aac1;">Loading all data...</td></tr>`;
}
try {
isFetching = true;
if (!fetchedData) {
fetchedData = await fetchAllDataForType(tableType);
}
isFetching = false;
if (!fetchedData || fetchedData.length === 0) return;
const keyFn = sortKeys[colIdx] || (() => 0);
const sorted = [...fetchedData].sort((a, b) => {
const va = keyFn(a), vb = keyFn(b);
return asc ? va - vb : vb - va;
});
table.setAttribute('data-estats-sort-active', '');
getNtPagination().forEach(el => { el.style.display = 'none'; });
renderPage(sorted, 0);
buildPagination(sorted);
} catch (e) {
isFetching = false;
console.error(LOG_PREFIX, 'Sort fetch error:', e);
tbody.innerHTML = `<tr class="table-row"><td class="table-cell" colspan="${colCount}" style="text-align:center;padding:20px;color:#d62f3a;">Failed to load data for sorting</td></tr>`;
}
});
});
}
async function handleEnhancedRacelog() {
if (!isFeatureEnabled('ENABLE_ENHANCED_RACELOG')) return;
const path = normalizePath(window.location.pathname);
if (!path.startsWith('/racelog') && !path.startsWith('/stats')) return;
if (path.startsWith('/stats')) {
await enhanceOpenRacelogResultsModal();
cleanupDetachedSortPagination();
scheduleSortPaginationCleanup();
return;
}
injectRacelogTrendGraphButton();
const workKey = getEnhancedRacelogWorkKey(path);
if (workKey === _lastEnhancedRacelogWorkKey) {
return;
}
_lastEnhancedRacelogWorkKey = workKey;
cleanupDetachedSortPagination();
scheduleSortPaginationCleanup();
bindRacelogTabReset();
// ── Inject extra column helpers ──
function injectHeaders(table, labels) {
const thead = table.querySelector('thead .table-row');
if (!thead || thead.querySelector('[data-estats-injected]')) return;
labels.forEach(label => {
const th = document.createElement('th');
th.className = 'table-cell';
th.setAttribute('data-estats-injected', '');
th.textContent = label;
thead.appendChild(th);
});
}
function injectRacelogCells(row, race) {
row.querySelectorAll('td[data-estats-injected]').forEach((cell) => cell.remove());
const errors = race.errs ?? 0;
const length = race.typed ?? 0;
const nitroUsed = (race.nitros ?? 0) > 0;
const errTd = document.createElement('td');
errTd.className = 'table-cell';
errTd.setAttribute('data-estats-injected', '');
errTd.style.color = errors > 0 ? '#d62f3a' : '#a6aac1';
errTd.textContent = String(errors);
row.appendChild(errTd);
const lenTd = document.createElement('td');
lenTd.className = 'table-cell';
lenTd.setAttribute('data-estats-injected', '');
lenTd.style.color = '#a6aac1';
lenTd.textContent = length > 0 ? length.toLocaleString() : '\u2014';
row.appendChild(lenTd);
const nitTd = document.createElement('td');
nitTd.className = 'table-cell';
nitTd.setAttribute('data-estats-injected', '');
nitTd.style.color = nitroUsed ? '#f3a81b' : '#a6aac1';
nitTd.textContent = nitroUsed ? 'Yes' : 'No';
row.appendChild(nitTd);
}
function injectRacelogPlaceholderCells(row) {
if (!row) return;
row.querySelectorAll('td[data-estats-injected]').forEach((cell) => cell.remove());
const placeholderValues = ['\u2014', '\u2014', '\u2014'];
placeholderValues.forEach((text) => {
const td = document.createElement('td');
td.className = 'table-cell';
td.setAttribute('data-estats-injected', '');
td.style.color = '#a6aac1';
td.textContent = text;
row.appendChild(td);
});
}
function clearRacelogCells(row) {
if (!row) return;
row.querySelectorAll('td[data-estats-injected]').forEach((cell) => cell.remove());
row.removeAttribute(RACELOG_ROW_PAGE_ATTR);
}
async function hydrateMainRacelogTable(table) {
if (!table) return;
injectHeaders(table, ['Errors', 'Length', 'Nitro']);
const rows = Array.from(table.querySelectorAll('tbody .table-row'));
if (!rows.length) return;
if (table.hasAttribute('data-estats-sort-active')) return;
const pageNum = getActiveRacelogPageNumber();
const pageKey = String(pageNum);
if (table.getAttribute(RACELOG_HYDRATION_ATTR) === pageKey) {
return;
}
const alreadyHydrated = table.getAttribute(RACELOG_PAGE_ATTR) === pageKey
&& rows.every((row) => row.getAttribute(RACELOG_ROW_PAGE_ATTR) === pageKey && row.querySelectorAll('td[data-estats-injected]').length === 3);
if (alreadyHydrated) {
return;
}
rows.forEach((row) => {
injectRacelogPlaceholderCells(row);
row.removeAttribute(RACELOG_ROW_PAGE_ATTR);
});
table.setAttribute(RACELOG_HYDRATION_ATTR, pageKey);
try {
const logs = await fetchRacelogPage(pageNum);
if (table.getAttribute(RACELOG_HYDRATION_ATTR) !== pageKey) {
return;
}
const liveRows = Array.from(table.querySelectorAll('tbody .table-row'));
liveRows.forEach((row, i) => {
const race = logs[i];
if (race) {
injectRacelogCells(row, race);
row.setAttribute(RACELOG_ROW_PAGE_ATTR, pageKey);
} else {
clearRacelogCells(row);
}
});
table.setAttribute(RACELOG_PAGE_ATTR, pageKey);
} catch (e) {
if (table.getAttribute(RACELOG_HYDRATION_ATTR) === pageKey) {
console.error(LOG_PREFIX, 'Racelog column injection error:', e);
}
} finally {
if (table.getAttribute(RACELOG_HYDRATION_ATTR) === pageKey) {
table.removeAttribute(RACELOG_HYDRATION_ATTR);
}
}
}
function injectTopSpeedCells(row, race) {
if (row.querySelector('[data-estats-injected]')) return;
const errors = race.errs ?? 0;
const length = race.typed ?? 0;
const errTd = document.createElement('td');
errTd.className = 'table-cell';
errTd.setAttribute('data-estats-injected', '');
errTd.style.color = errors > 0 ? '#d62f3a' : '#a6aac1';
errTd.textContent = String(errors);
row.appendChild(errTd);
const lenTd = document.createElement('td');
lenTd.className = 'table-cell';
lenTd.setAttribute('data-estats-injected', '');
lenTd.style.color = '#a6aac1';
lenTd.textContent = length > 0 ? length.toLocaleString() : '\u2014';
row.appendChild(lenTd);
}
// ── Set up sorting on daily and monthly tables (no extra columns) ──
document.querySelectorAll('.table--striped').forEach(t => {
if (t.hasAttribute('data-estats-sortable')) return;
const type = detectTableType(t);
if (type === 'daily' || type === 'monthly') {
setupCrossPageSorting(t, type);
}
});
// ── Main racelog table: inject 3 extra columns (Errors, Length, Nitro) ──
const racelogTable = document.querySelector('.table--striped.table--selectable');
if (racelogTable) {
if (!racelogTable.hasAttribute(RACELOG_ATTR)) {
racelogTable.setAttribute(RACELOG_ATTR, '');
}
await hydrateMainRacelogTable(racelogTable);
// Set up sorting AFTER columns are injected so all 11 headers get handlers
if (!racelogTable.hasAttribute('data-estats-sortable')) {
setupCrossPageSorting(racelogTable, 'racelog');
}
}
// ── Top speeds table: inject 2 extra columns (Errors, Length — no Nitro in API) ──
document.querySelectorAll('.table--striped').forEach(t => {
if (t === racelogTable) return;
if (t.hasAttribute('data-estats-sortable')) return;
const type = detectTableType(t);
if (type === 'topSpeed') {
injectHeaders(t, ['Errors', 'Length']);
// Fetch from the correct topspeeds API endpoint
fetchAllDataForType('topSpeed').then(records => {
const rows = t.querySelectorAll('tbody .table-row');
rows.forEach((row, i) => {
if (records[i]) injectTopSpeedCells(row, records[i]);
});
// Set up sorting AFTER columns are injected
setupCrossPageSorting(t, 'topSpeed');
}).catch(e => {
console.error(LOG_PREFIX, 'Top speeds column injection error:', e);
setupCrossPageSorting(t, 'topSpeed');
});
}
});
await enhanceOpenRacelogResultsModal();
scheduleSortPaginationCleanup();
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 8: League XP-Races Calculator (/leagues)
// ─────────────────────────────────────────────────────────────────────────────
const LEAGUE_CALC_ATTR = 'data-estats-league-calc';
function cleanupLeagueCalculator() {
document.querySelectorAll(`[${LEAGUE_CALC_ATTR}]`).forEach((el) => el.remove());
}
function handleLeagueCalculator() {
if (!isFeatureEnabled('ENABLE_LEAGUE_CALCULATOR')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/leagues') return;
// Find self row in standings
const selfRow = document.querySelector('tr.table-row.is-self');
if (!selfRow) return;
const selfXPCell = selfRow.querySelector('td.table-cell.leagues--standings--experience');
const selfRacesCell = selfRow.querySelector('td.table-cell.leagues--standings--played');
if (!selfXPCell || !selfRacesCell) return;
const parseNum = (str) => { const n = parseInt(str.replace(/,/g, ''), 10); return isNaN(n) ? 0 : n; };
const selfXP = parseNum(selfXPCell.textContent.trim());
const selfRaces = parseNum(selfRacesCell.textContent.trim());
if (selfXP <= 0 || selfRaces <= 0) return;
const xpPerRace = selfXP / selfRaces;
// Get all XP cells and inject race difference
const allXPCells = document.querySelectorAll('td.table-cell.leagues--standings--experience');
allXPCells.forEach(cell => {
if (cell.querySelector('[data-estats-league-calc]')) return;
const theirXP = parseNum(cell.textContent.trim());
if (theirXP === selfXP) return; // Skip self
const racesToMatch = Math.ceil(theirXP / xpPerRace);
const racesDiff = racesToMatch - selfRaces;
const badge = document.createElement('div');
badge.setAttribute('data-estats-league-calc', '');
badge.style.cssText = 'font-size:13px;font-weight:700;margin-top:2px;';
if (selfXP > theirXP) {
// You're ahead — they need this many races to catch you
badge.style.color = '#d62f3a';
badge.textContent = `${racesDiff}`;
} else {
// They're ahead — you need this many more races
badge.style.color = '#4ade80';
badge.textContent = `+${racesDiff}`;
}
badge.title = `~${Math.abs(racesDiff)} races difference at your avg ${Math.round(xpPerRace).toLocaleString()} XP/race`;
cell.appendChild(badge);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 9: Tournament Wins (/leagues)
// ─────────────────────────────────────────────────────────────────────────────
const TOURNAMENT_WINS_ATTR = 'data-estats-tournament-wins';
function cleanupTournamentWins() {
document.querySelectorAll(`[${TOURNAMENT_WINS_ATTR}]`).forEach((el) => el.remove());
}
async function handleTournamentWins() {
if (!isFeatureEnabled('ENABLE_TOURNAMENT_WINS')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/leagues') return;
if (document.querySelector(`[${TOURNAMENT_WINS_ATTR}]`)) return;
const user = getCurrentUser();
if (!user) return;
const personalWins = user.tournamentsWon || 0;
// Inject win count into the Personal toggle label
const personalLabel = document.querySelector('label[for="showindividual"]');
if (personalLabel && !personalLabel.querySelector(`[${TOURNAMENT_WINS_ATTR}]`)) {
const span = document.createElement('span');
span.setAttribute(TOURNAMENT_WINS_ATTR, '');
span.style.cssText = 'margin-left:6px;';
span.textContent = `| Wins: ${personalWins.toLocaleString()}`;
personalLabel.appendChild(span);
}
// Fetch and inject team tournament wins
if (user.tag) {
const teamLabel = document.querySelector('label[for="showteam"]');
if (teamLabel && !teamLabel.querySelector(`[${TOURNAMENT_WINS_ATTR}]`)) {
let teamWins = 0;
try {
const data = await apiFetch('/api/v2/leagues/team/activity');
teamWins = data?.results?.tournamentsWon ?? 0;
} catch (e) {
console.error(LOG_PREFIX, 'Tournament wins fetch error:', e);
}
const span = document.createElement('span');
span.setAttribute(TOURNAMENT_WINS_ATTR, '');
span.style.cssText = 'margin-left:6px;';
span.textContent = `| Wins: ${teamWins.toLocaleString()}`;
teamLabel.appendChild(span);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FEATURE 8: Garage Car Count (/garage)
// ─────────────────────────────────────────────────────────────────────────────
const GARAGE_CAR_COUNT_ATTR = 'data-estats-garage-carcount';
function cleanupGarageCarCount() {
document.querySelectorAll(`[${GARAGE_CAR_COUNT_ATTR}]`).forEach((el) => el.remove());
}
function handleGarageCarCount() {
if (!isFeatureEnabled('ENABLE_GARAGE_CAR_COUNT')) return;
const path = normalizePath(window.location.pathname);
if (path !== '/garage') return;
// Already injected?
if (document.querySelector(`[${GARAGE_CAR_COUNT_ATTR}]`)) return;
// Get car count from current user's data (persist:nt)
const user = getCurrentUser();
if (!user) return;
const totalCars = user.totalCars || user.carsOwned || 0;
if (!totalCars) return;
// Find the "My Cars" heading on the garage page
const headings = document.querySelectorAll('h1, h2, h3');
let carsHeading = null;
for (const h of headings) {
const txt = h.textContent.trim();
if (txt === 'My Cars' || txt === 'Cars') {
carsHeading = h;
break;
}
}
if (!carsHeading) return;
const countSpan = document.createElement('span');
countSpan.setAttribute(GARAGE_CAR_COUNT_ATTR, '');
countSpan.className = 'tbs';
countSpan.style.cssText = 'margin-left:8px;';
countSpan.textContent = `| ${totalCars}`;
carsHeading.appendChild(countSpan);
}
// ─────────────────────────────────────────────────────────────────────────────
// STARTUP
// ─────────────────────────────────────────────────────────────────────────────
function startEnhancedStats() {
initObserverManager();
applyAllLiveSettingSideEffects();
window.NTObserverManager.register('enhanced-stats', () => {
const path = normalizePath(window.location.pathname);
try { handleSessionCounter(); } catch (e) { console.error(LOG_PREFIX, 'Session counter error:', e); }
if (path.startsWith('/racer/')) {
try { handleHiddenStats(); } catch (e) { console.error(LOG_PREFIX, 'Hidden stats error:', e); }
}
if (path === '/race' || path.startsWith('/race/')) {
try { hookRaceServer(); } catch (e) { console.error(LOG_PREFIX, 'Race hook error:', e); }
try { handleRaceEnhancements(); } catch (e) { console.error(LOG_PREFIX, 'Race enhance error:', e); }
try { handleWPMCurve(); } catch (e) { console.error(LOG_PREFIX, 'WPM curve error:', e); }
}
if (path === '/stats') {
try { handleEnhancedStatsPage(); } catch (e) { console.error(LOG_PREFIX, 'Stats page error:', e); }
}
if (path.startsWith('/racelog') || path.startsWith('/stats')) {
try { handleEnhancedRacelog(); } catch (e) { console.error(LOG_PREFIX, 'Racelog error:', e); }
}
if (path === '/leagues') {
try { handleLeagueCalculator(); } catch (e) { console.error(LOG_PREFIX, 'League calc error:', e); }
try { handleTournamentWins(); } catch (e) { console.error(LOG_PREFIX, 'Tournament wins error:', e); }
}
if (path === '/garage') {
try { handleGarageCarCount(); } catch (e) { console.error(LOG_PREFIX, 'Garage car count error:', e); }
}
});
// Prefetch summary stats in the background (keeps cache warm for /stats page)
prefetchSummaryStats().catch(e => console.error(LOG_PREFIX, 'Summary prefetch error:', e));
// Initial run for global features
try { handleSessionCounter(); } catch (e) { console.error(LOG_PREFIX, 'Session counter error:', e); }
// Retry global elements (dropdown may not exist yet)
let retryCount = 0;
const retryGlobal = setInterval(() => {
retryCount++;
if (retryCount > 30) { clearInterval(retryGlobal); return; }
try {
const hasCounter = document.querySelector(`[${SESSION_COUNTER_ATTR}]`);
if (!hasCounter) handleSessionCounter();
if (hasCounter) clearInterval(retryGlobal);
} catch { /* ignore */ }
}, 500);
// ── Race page polling fallback ──
// MutationObserver can be unreliable when multiple userscripts are active.
// Poll for race results like other scripts (Racer Badges, Race Options) do.
const path = normalizePath(window.location.pathname);
const isTopRaceShell = (path === '/race' || path.startsWith('/race/'))
&& window.top === window
&& !document.getElementById('raceContainer');
if ((path === '/race' || path.startsWith('/race/')) && !isTopRaceShell) {
const racePoller = setInterval(() => {
try {
const rc = document.getElementById('raceContainer');
if (rc) hookRaceServer();
if (rc && rc.querySelector('.race-results')) {
handleRaceEnhancements();
handleWPMCurve();
}
} catch (e) { console.error(LOG_PREFIX, 'Race poll error:', e); }
}, 500);
// Stop polling after 10 minutes (race should be long done)
setTimeout(() => clearInterval(racePoller), 600000);
}
}
if (document.body) {
startEnhancedStats();
} else {
const bodyWait = new MutationObserver(() => {
if (document.body) {
bodyWait.disconnect();
startEnhancedStats();
}
});
bodyWait.observe(document.documentElement, { childList: true });
}
})();