tr nq stats

nq stats

// ==UserScript==
// @name         tr nq stats
// @namespace    http://tampermonkey.net/
// @version      11.0
// @description  nq stats
// @author       aaaaaa
// @match        https://play.typeracer.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // basic script settings things like debug mode and animation speed
    const showDebugMessages = true;
    const popupAnimationDuration = 2000; // how long those little numbers fly up
    const statsBoxDefaultRight = '15px';
    const statsBoxRacingRight = '220px'; // how far it's shifting when in a race

    function logMessage(message) {
        if (showDebugMessages) {
            console.log('[TR NQ Stats v11] ' + message);
        }
    }
    // these vars hold all the stats we're tracking like wpm and streak
    let cumulativeWPM = parseFloat(GM_getValue('tr_totalWPM_v11', '0.0'));
    let cumulativeAccuracy = parseFloat(GM_getValue('tr_totalAccuracy_v11', '0.0'));
    let completedRaceCount = parseInt(GM_getValue('tr_racesCompleted_v11', '0'));
    let consecutiveWins = parseInt(GM_getValue('tr_currentStreak_v11', '0'));
    let lowestWPMSpeed = parseFloat(GM_getValue('tr_worstWPM_v11', 'Infinity')); // start high so any race is lower
    let highestWPMSpeed = parseFloat(GM_getValue('tr_bestWPM_v11', '-Infinity')); // start low so any race is higher

    let isPlayerRacing = false; // simple flag are we in a race right now
    let resultsLoggedForCurrentRace = false; // did we already grab stats for this one race
    let trackedMainMenuButton = null; // keep a reference to the main menu button so we only attach the event listener once

    // this is the div element for our stats display box
    const statsDisplayElement = document.createElement('div');
    statsDisplayElement.id = 'tr_nq_stats_blackout_v11';

    // function to update the html inside the stats box with current numbers
    function refreshStatsPanel() {
        const averageWPMToDisplay = completedRaceCount > 0 ? (cumulativeWPM / completedRaceCount).toFixed(1) : '---';
        const averageAccuracyToDisplay = completedRaceCount > 0 ? (cumulativeAccuracy / completedRaceCount).toFixed(1) : '---';
        const worstWPMToDisplay = (completedRaceCount > 0 && lowestWPMSpeed !== Infinity) ? lowestWPMSpeed.toFixed(1) : '---';
        const bestWPMToDisplay = (completedRaceCount > 0 && highestWPMSpeed !== -Infinity) ? highestWPMSpeed.toFixed(1) : '---';

        statsDisplayElement.innerHTML = `
            <div class="nq-item-blackout">
                <span>Avg WPM:</span>
                <span id="nq-value-avg-wpm">${averageWPMToDisplay}</span>
            </div>
            <div class="nq-item-blackout">
                <span>Avg Acc:</span>
                <span id="nq-value-avg-acc">${averageAccuracyToDisplay}%</span>
            </div>
            <div class="nq-item-blackout">
                <span>Streak:</span>
                <span id="nq-value-streak">${consecutiveWins}</span>
            </div>
            <div class="nq-item-blackout nq-separator-blackout"></div>
            <div class="nq-item-blackout">
                <span>Worst:</span>
                <span id="nq-value-worst-wpm">${worstWPMToDisplay}</span>
            </div>
            <div class="nq-item-blackout">
                <span>Best:</span>
                <span id="nq-value-best-wpm">${bestWPMToDisplay}</span>
            </div>
        `;
    }

    // css stuff
    function setupStatsPanel() {
        GM_addStyle(`
            #tr_nq_stats_blackout_v11 {
                position: fixed;
                top: 70px;
                right: ${statsBoxDefaultRight};
                background-color: #0A0A0A;
                color: #AAAAAA;
                padding: 8px 12px;
                border-radius: 4px;
                font-family: Arial, Helvetica, sans-serif;
                font-size: 12px;
                line-height: 1.4;
                z-index: 10010;
                border: 1px solid #222222;
                min-width: 135px;
                box-shadow: 0 1px 3px rgba(0,0,0,0.3);
                display: block !important;
                transition: right 0.3s ease-in-out; /* for that smooth slide */
            }
            /* just item styling */
            .nq-item-blackout {
                display: flex; justify-content: space-between; margin-bottom: 3px;
            }
            .nq-item-blackout:last-child { margin-bottom: 0; }
            .nq-item-blackout span:first-child { color: #888888; } /* label color */
            .nq-item-blackout span:last-child { color: #BBBBBB; font-weight: bold; } /* value color */
            .nq-separator-blackout { height: 1px; background-color: #333333; margin-top: 5px; margin-bottom: 5px; }

            /* for the stat change popups */
            .stat-diff-animation {
                position: fixed; font-size: 11px; font-weight: bold; padding: 1px 3px;
                border-radius: 2px; opacity: 0;
                animation: popAndFade ${popupAnimationDuration / 1000}s ease-out forwards;
                pointer-events: none; z-index: 10011; /* needs to be above the stats box */
            }
            @keyframes popAndFade { /* define the actual animation */
                0% { opacity: 0; transform: translateY(5px) scale(0.8); }
                20% { opacity: 1; transform: translateY(-8px) scale(1.1); }
                80% { opacity: 1; transform: translateY(-12px) scale(1); }
                100% { opacity: 0; transform: translateY(-20px) scale(0.9); }
            }
        `);
        document.body.appendChild(statsDisplayElement); // stick it on the page
        statsDisplayElement.style.display = 'block';
        refreshStatsPanel(); // fill it with initial data
        logMessage("Stats display initialized.");
    }

    function createStatPopupAnimation(displayValueElementId, valueDifference, isValuePercentage = false) {
        const animatedElement = document.getElementById(displayValueElementId); // find where the stat number is
        if (!animatedElement || isNaN(valueDifference) || valueDifference === 0) return; // no point if no change or can't find it

        const targetElementRect = animatedElement.getBoundingClientRect(); // get its position
        const animationPopup = document.createElement('span');
        animationPopup.className = 'stat-diff-animation';
        const plusOrMinusSign = valueDifference > 0 ? '+' : '';
        animationPopup.textContent = `${plusOrMinusSign}${valueDifference.toFixed(1)}${isValuePercentage ? '%' : ''}`;

        // color it green for good red for bad
        if (valueDifference > 0) {
            animationPopup.style.color = '#4CAF50'; animationPopup.style.backgroundColor = 'rgba(76, 175, 80, 0.1)';
        } else {
            animationPopup.style.color = '#F44336'; animationPopup.style.backgroundColor = 'rgba(244, 67, 54, 0.1)';
        }

        // position it next to the stat
        animationPopup.style.top = `${targetElementRect.top + (targetElementRect.height / 2) - 7}px`;
        animationPopup.style.left = `${targetElementRect.right + 5}px`;

        document.body.appendChild(animationPopup);
        setTimeout(() => { // clean it up after animation
            if (animationPopup.parentNode) animationPopup.parentNode.removeChild(animationPopup);
        }, popupAnimationDuration);
    }

    // writes the current stats to GM_setValue so they persist across sessions
    function saveCurrentStats() {
        GM_setValue('tr_totalWPM_v11', cumulativeWPM.toString());
        GM_setValue('tr_totalAccuracy_v11', cumulativeAccuracy.toString());
        GM_setValue('tr_racesCompleted_v11', completedRaceCount.toString());
        GM_setValue('tr_currentStreak_v11', consecutiveWins.toString());
        GM_setValue('tr_worstWPM_v11', lowestWPMSpeed.toString());
        GM_setValue('tr_bestWPM_v11', highestWPMSpeed.toString());
    }

    // handles what happens if the user bails on a race via the main menu button
    function processMainMenuClick(event) {
        logMessage('Main Menu (leave race) clicked.');
        if (isPlayerRacing) { // only if they were actually in a race
            logMessage('Quit detected: Resetting ALL stats and position.');
            consecutiveWins = 0; cumulativeWPM = 0.0; cumulativeAccuracy = 0.0; completedRaceCount = 0;
            lowestWPMSpeed = Infinity; highestWPMSpeed = -Infinity; // full reset
            isPlayerRacing = false; resultsLoggedForCurrentRace = true; // pretend race ended so it doesnt try to parse again
            statsDisplayElement.style.right = statsBoxDefaultRight; // slide box back
            saveCurrentStats(); refreshStatsPanel();
        }
    }

    // finds the "leave race"  link and makes sure our click handler is on it
    // also makes sure not to add it multiple times if the button object changes
    function setupMainMenuButtonListener() {
        const mainMenuLinkElement = document.querySelector('a.raceLeaveLink'); // the actual typeracer button
        if (mainMenuLinkElement) {
            if (trackedMainMenuButton !== mainMenuLinkElement) { // only if its a new button or first time
                if (trackedMainMenuButton) trackedMainMenuButton.removeEventListener('click', processMainMenuClick); // remove old one if any
                mainMenuLinkElement.addEventListener('click', processMainMenuClick);
                trackedMainMenuButton = mainMenuLinkElement; // remember this button
            }
        } else { // if button disappeared remove listener from old one
             if (trackedMainMenuButton) {
                trackedMainMenuButton.removeEventListener('click', processMainMenuClick);
                trackedMainMenuButton = null;
            }
        }
    }

    // tries to find the wpm and accuracy figures from the page after a race finishes
    function parseAndStoreRaceResults() {
        logMessage('Extracting race results.');
        let wpm, accuracy;
        // these selectors are specific to how typeracer shows your stats post-race
        const raceWpmElement = document.querySelector('div.tblOwnStatsNumber[title*="wpm"]');
        const raceAccuracyElement = Array.from(document.querySelectorAll('div.tblOwnStatsNumber')).find(el => el.textContent.includes('%'));

        if (raceWpmElement) wpm = parseFloat(raceWpmElement.getAttribute('title')) || parseFloat(raceWpmElement.textContent); // try title first then text
        if (raceAccuracyElement) accuracy = parseFloat(raceAccuracyElement.textContent);

        if (!isNaN(wpm) && !isNaN(accuracy)) { // got valid numbers
            let oldAverageWPM = NaN, oldAverageAccuracy = NaN;
            const previousRaceCount = completedRaceCount;
            if (previousRaceCount > 0) { // need this to calculate change in average
                oldAverageWPM = cumulativeWPM / previousRaceCount;
                oldAverageAccuracy = cumulativeAccuracy / previousRaceCount;
            }

            // update records
            if (wpm > highestWPMSpeed) highestWPMSpeed = wpm;
            if (wpm < lowestWPMSpeed) lowestWPMSpeed = wpm;
            cumulativeWPM += wpm; cumulativeAccuracy += accuracy; completedRaceCount++; consecutiveWins++;

            logMessage(`Race recorded: WPM=${wpm.toFixed(1)}, Acc=${accuracy.toFixed(1)}%.`);
            saveCurrentStats(); refreshStatsPanel(); // save and show new numbers

            // animate the changes
            if (previousRaceCount > 0) {
                const newAverageWPM = cumulativeWPM / completedRaceCount;
                const newAverageAccuracy = cumulativeAccuracy / completedRaceCount;
                createStatPopupAnimation('nq-value-avg-wpm', newAverageWPM - oldAverageWPM);
                createStatPopupAnimation('nq-value-avg-acc', newAverageAccuracy - oldAverageAccuracy, true);
            } else if (completedRaceCount === 1) { // special handling for the very first race since there's no old average
                 createStatPopupAnimation('nq-value-avg-wpm', wpm);
                 createStatPopupAnimation('nq-value-avg-acc', accuracy, true);
            }
        } else {
            logMessage(`Error parsing WPM/Accuracy. WPM: ${wpm}, Acc: ${accuracy}`);
            refreshStatsPanel(); // important to refresh the panel even if parsing fails so it doesn't look stuck
        }
    }

    // this is the core logic checks page elements to see if we are in a race or not
    function updateRaceActivityStatus() {
        const statusLabel = document.querySelector('.gameStatusLabel'); // like "Go!" or "The race is on"
        const statusText = statusLabel ? statusLabel.innerText.trim() : '';
        const textInputElement = document.querySelector('input.txtInput');
        // checks if the text input field is actually enabled and visible good indicator of race active
        const isTypingInputActive = textInputElement && !textInputElement.disabled && textInputElement.offsetParent !== null;
        // check if the results table numbers are visible
        const areResultsDisplayed = document.querySelector('div.tblOwnStatsNumber[title*="wpm"]') && Array.from(document.querySelectorAll('div.tblOwnStatsNumber')).find(el => el.textContent.includes('%'));

        let hasRaceJustStarted = false;
        let hasRaceJustEnded = false;

        // race start detection: not racing now but game status says go and input is active
        if (!isPlayerRacing && (statusText === 'Go!' || statusText.startsWith('The race is on'))) {
            if (isTypingInputActive) {
                hasRaceJustStarted = true;
                isPlayerRacing = true; resultsLoggedForCurrentRace = false; // reset for new race
                if (showDebugMessages) logMessage('Race started.');
            }
        } else if (isPlayerRacing) { // if we think we're racing check for end conditions
            // race end detection (option 1): results table is visible and we havent parsed them yet
            if (areResultsDisplayed && !resultsLoggedForCurrentRace) {
                hasRaceJustEnded = true;
                if (showDebugMessages) logMessage('Race finished: Results visible.');
                parseAndStoreRaceResults();
            }
            // race end detection (option 2): input is inactive game status says finished and not parsed yet
            else if (!isTypingInputActive && (statusText.startsWith('You finished') || statusText === 'The race has ended.') && !resultsLoggedForCurrentRace) {
                hasRaceJustEnded = true;
                if (showDebugMessages) logMessage('Race finished: Status label & input inactive.');
                setTimeout(() => { // sometimes the results aren't instantly in the dom so a small delay helps
                    if (document.querySelector('div.tblOwnStatsNumber[title*="wpm"]')) parseAndStoreRaceResults();
                    else { if (showDebugMessages) logMessage('Results not found after delay.'); refreshStatsPanel(); saveCurrentStats(); }
                }, 250);
            }
            // race end detection (option 3): fallback if critical race elements disappear suggesting user left the race page
            else if (!statusLabel && !isTypingInputActive && !resultsLoggedForCurrentRace && !window.location.hash.includes("#!race")) {
                hasRaceJustEnded = true;
                if (showDebugMessages) logMessage('Race likely ended abruptly (navigated away or context lost).');
                refreshStatsPanel(); saveCurrentStats(); // save whatever we had
            }

            if (hasRaceJustEnded) {
                isPlayerRacing = false;
                resultsLoggedForCurrentRace = true; // mark as done for this race
            }
        }

        // logic for shifting the stats box left during a race and back again after
        if (isPlayerRacing && statsDisplayElement.style.right !== statsBoxRacingRight) {
            if (showDebugMessages) logMessage('Shifting stats display left for race.');
            statsDisplayElement.style.right = statsBoxRacingRight;
        } else if (!isPlayerRacing && statsDisplayElement.style.right !== statsBoxDefaultRight) {
             // dont want it snapping back if the user is just looking at post-race stats
             if (!areResultsDisplayed && (!statusLabel || !(statusText.startsWith('You finished') || statusText === 'The race has ended.'))) {
                if (showDebugMessages) logMessage('Resetting stats display to default position.');
                statsDisplayElement.style.right = statsBoxDefaultRight;
             }
        }

        if(hasRaceJustStarted || hasRaceJustEnded) refreshStatsPanel(); // call refreshStatsPanel if the race state changed to show new data
        setupMainMenuButtonListener(); // re-check the main menu button listener just in case it got removed or changed by TR's js
    }

    logMessage('Script starting (NQ Stats Final Tweaks v11).');
    setupStatsPanel(); // call to initialize the stats display when the script loads

    // MutationObserver is pretty handy for reacting to dynamic page updates on typeracer
    const pageChangeObserver = new MutationObserver(updateRaceActivityStatus);
    // watch pretty much everything for changes
    pageChangeObserver.observe(document.documentElement, { childList: true, subtree: true });
    // initial check after page load sometimes stuff isnt ready immediately
    setTimeout(updateRaceActivityStatus, 750);

    // START: Added code for resetting stats on page unload during a race
    window.addEventListener('beforeunload', function (e) {
        if (isPlayerRacing) {
            logMessage('Page is being unloaded (refresh/close) while player is racing. Resetting all stats.');
            // Reset local script variables to their initial states
            cumulativeWPM = 0.0;
            cumulativeAccuracy = 0.0;
            completedRaceCount = 0;
            consecutiveWins = 0;
            lowestWPMSpeed = Infinity;
            highestWPMSpeed = -Infinity;

            // Persist these reset values
            saveCurrentStats();
            // Note: No need to call refreshStatsPanel() as the page is unloading.
        } else {
            logMessage('Page is being unloaded (refresh/close) while player is NOT racing. Stats will be preserved.');
            // Stats are saved after each race, so they should be up-to-date.
        }
    });
    // END: Added code

})();