Greasy Fork is available in English.

Custom Race Filter

Adds filtering and sorting. Fixed unstable sort issue with a permanent ID tie-breaker.

// ==UserScript==
// @name         Custom Race Filter
// @version      0.0.33
// @description  Adds filtering and sorting. Fixed unstable sort issue with a permanent ID tie-breaker.
// @author       Elaine [2047176]
// @match        https://www.torn.com/loader.php?sid=racing*
// @grant        none
// @license      MIT
// @namespace    http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';

    console.log("CRF: Running v0.0.33 - Implemented stable sort.");

    // --- Constants & State ---
    const TRACKS = ["Speedway", "Parkland", "Withdrawal", "Industrial", "Vector", "Mudpit", "Seaside", "Two Islands", "Docks", "Commerce", "Sewage", "Meltdown", "Uptown", "Hammerhead", "Convict"];
    const STORAGE_PREFIX = 'crf_';
    const ui = { collapsibleBox: null, style: null };
    let sortState = { key: 'none', direction: 'asc' };
    let idCounter = 0; // Counter for stable sort IDs

    const STORAGE_KEYS = {
        HIDE_PW: `${STORAGE_PREFIX}hide_passworded`,
        ONLY_ANY: `${STORAGE_PREFIX}only_any_car`,
        FEE_ENABLED: `${STORAGE_PREFIX}fee_filter_enabled`,
        FEE_MIN: `${STORAGE_PREFIX}fee_min`,
        FEE_MAX: `${STORAGE_PREFIX}fee_max`,
        HIDE_FULL: `${STORAGE_PREFIX}hide_full_races`,
        LAPS_ENABLED: `${STORAGE_PREFIX}laps_filter_enabled`,
        LAPS_MIN: `${STORAGE_PREFIX}laps_min`,
        LAPS_MAX: `${STORAGE_PREFIX}laps_max`,
        TIME_ENABLED: `${STORAGE_PREFIX}time_filter_enabled`,
        TIME_H: `${STORAGE_PREFIX}time_h`,
        TIME_M: `${STORAGE_PREFIX}time_m`,
        TRACK_FILTER_ENABLED: `${STORAGE_PREFIX}track_filter_enabled`,
        TRACK_CHECKBOX_PREFIX: `${STORAGE_PREFIX}track_`
    };

    function parseTimeToMinutes(timeText) {
        if (timeText === 'waiting') return Infinity;
        const hMatch = timeText.match(/(\d+)\s*h/);
        const mMatch = timeText.match(/(\d+)\s*m/);
        return ((hMatch ? parseInt(hMatch[1], 10) : 0) * 60) + (mMatch ? parseInt(mMatch[1], 10) : 0);
    }

    /**
     * Sorts the race list based on the global sortState object.
     */
    function sortRaces() {
        if (sortState.key === 'none') return;
        const raceList = document.querySelector('.events-list');
        if (!raceList) return;

        const raceItems = Array.from(raceList.children);

        raceItems.sort((a, b) => {
            let valA, valB;

            if (sortState.key === 'startTime') {
                const timeTextA = a.querySelector('.startTime')?.textContent.trim().toLowerCase() || 'waiting';
                const timeTextB = b.querySelector('.startTime')?.textContent.trim().toLowerCase() || 'waiting';
                valA = parseTimeToMinutes(timeTextA);
                valB = parseTimeToMinutes(timeTextB);
            }

            // Primary sort criteria
            if (valA < valB) return -1;
            if (valA > valB) return 1;

            // --- STABILITY FIX: If primary values are equal, use the permanent data-crf-id as a tie-breaker ---
            const idA = parseInt(a.dataset.crfId, 10);
            const idB = parseInt(b.dataset.crfId, 10);
            return idA - idB;
        });

        if (sortState.direction === 'desc') {
            raceItems.reverse();
        }

        // Re-append the sorted elements to the DOM
        raceItems.forEach(item => raceList.appendChild(item));
    }

    /**
     * Applies all active filters to the race list, then sorts it.
     */
    function applyFiltersAndSort() {
        const raceItems = document.querySelectorAll('.events-list > li');
        if (raceItems.length === 0) return;

        // --- STABILITY ID ---
        // Ensure every race item has a unique, persistent ID for stable sorting.
        raceItems.forEach(item => {
            if (!item.dataset.crfId) {
                item.dataset.crfId = idCounter++;
            }
        });

        // (Filter logic remains the same as previous version)
        const hidePassworded = document.getElementById('crf-hide-passworded')?.checked;
        const onlyAnyCar = document.getElementById('crf-only-any')?.checked;
        const hideFull = document.getElementById('crf-hide-full')?.checked;
        const feeFilterEnabled = document.getElementById('crf-fee-enabled')?.checked;
        const lapsFilterEnabled = document.getElementById('crf-laps-enabled')?.checked;
        const timeFilterEnabled = document.getElementById('crf-time-enabled')?.checked;
        const trackFilterEnabled = document.getElementById('crf-track-filter-enabled')?.checked;

        const minFee = parseFloat(document.getElementById('crf-min-fee')?.value) || 0;
        const maxFee = document.getElementById('crf-max-fee')?.value !== '' ? parseFloat(document.getElementById('crf-max-fee')?.value) : Infinity;
        const minLaps = parseFloat(document.getElementById('crf-min-laps')?.value) || 0;
        const maxLaps = document.getElementById('crf-max-laps')?.value !== '' ? parseFloat(document.getElementById('crf-max-laps')?.value) : Infinity;
        const maxTimeH = parseFloat(document.getElementById('crf-max-time-h')?.value) || 0;
        const maxTimeM = parseFloat(document.getElementById('crf-max-time-m')?.value) || 0;
        const maxTotalMinutes = (maxTimeH * 60) + maxTimeM;

        const allowedTracks = new Set();
        if (trackFilterEnabled) {
            TRACKS.forEach(track => {
                const trackId = `crf-track-${track.toLowerCase().replace(/\s/g, '-')}`;
                if (document.getElementById(trackId)?.checked) allowedTracks.add(track);
            });
        }

        raceItems.forEach(item => {
            let shouldBeVisible = true;
            if (hidePassworded && !!item.querySelector('.password.protected')) shouldBeVisible = false;
            if (shouldBeVisible && onlyAnyCar) {
                const carText = item.querySelector('li.car > span.d-hide')?.textContent.trim().toLowerCase() || '';
                if (carText !== 'any class' && carText !== 'any car') shouldBeVisible = false;
            }
            if (shouldBeVisible && feeFilterEnabled) {
                const feeText = item.querySelector('li.fee')?.textContent.trim() || '$0';
                const feeValue = parseFloat(feeText.replace(/[^0-9.]/g, ''));
                if (feeValue < minFee || feeValue > maxFee) shouldBeVisible = false;
            }
            if (shouldBeVisible && hideFull) {
                const driversElement = item.querySelector('li.drivers');
                let driversText = '';
                driversElement?.childNodes.forEach(node => {
                    if (node.nodeType === 3 && node.textContent.includes('/')) driversText = node.textContent.trim();
                });
                if (driversText) {
                    const parts = driversText.split('/').map(s => parseInt(s.trim(), 10));
                    if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[0] >= parts[1]) {
                        shouldBeVisible = false;
                    }
                }
            }
            if (shouldBeVisible && lapsFilterEnabled) {
                const lapsText = item.querySelector('li.track span.laps')?.textContent.trim() || '';
                const lapCount = parseInt(lapsText.replace(/\D/g, ''), 10);
                if (!isNaN(lapCount) && (lapCount < minLaps || lapCount > maxLaps)) shouldBeVisible = false;
            }
            if (shouldBeVisible && timeFilterEnabled) {
                const timeText = item.querySelector('li.startTime')?.textContent.trim().toLowerCase() || '';
                if (timeText !== 'waiting') {
                    const raceTotalMinutes = parseTimeToMinutes(timeText);
                    if (raceTotalMinutes > maxTotalMinutes) shouldBeVisible = false;
                }
            }
            if (shouldBeVisible && trackFilterEnabled) {
                const trackText = item.querySelector('li.track')?.firstChild.textContent.trim();
                if (trackText && !allowedTracks.has(trackText)) shouldBeVisible = false;
            }
            item.style.display = shouldBeVisible ? '' : 'none';
        });

        sortRaces();
    }

    function createUI() {
        if (ui.style) return;
        console.log("CRF: Creating permanent UI elements.");

        const css = `
            .crf-container { margin: 10px 0; }
            .crf-header { background: #333; color: #fff; padding: 8px 10px; font-weight: bold; border-top-left-radius: 5px; border-top-right-radius: 5px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border: 1px solid #444;}
            .crf-content { padding: 15px; display: none; }
            .crf-content.visible { display: block; }
            .crf-header.open { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
            .crf-header .arrow { transition: transform 0.3s; font-size: 16px; }
            .crf-header.open .arrow { transform: rotate(180deg); }
            .crf-options { display: flex; flex-direction: column; gap: 12px; }
            .crf-options label, .crf-filter-row { display: flex; align-items: center; font-size: 14px; color: #ddd !important; }
            .crf-options label > input[type="checkbox"] { margin-right: 8px; }
            .crf-options input[type="checkbox"] { height: 16px; width: 16px; accent-color: #555; }
            .crf-inputs-group { display: flex; gap: 5px; align-items: center; margin-left: auto; }
            .crf-inputs-group input[type="number"] { width: 70px; padding: 4px; background-color: #eee; border: 1px solid #999; border-radius: 3px; color: #333; }
            .crf-fee-inputs input[type="number"] { width: 100px; }
            .crf-inputs-group input:disabled { background-color: #ccc; cursor: not-allowed; }
            .crf-track-filter { position: relative; }
            #crf-track-dropdown { display: none; position: absolute; background-color: #333; min-width: 220px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.4); z-index: 10; border-radius: 5px; padding: 10px; margin-top: 5px; border: 1px solid #555; }
            #crf-track-dropdown label { display: block; padding: 5px; border-radius: 3px; cursor: pointer; }
            #crf-track-dropdown label:hover { background-color: #555; }
            .custom-events-wrap .title > li[data-sortable] { cursor: pointer; }
            .custom-events-wrap .title > li[data-sortable]:hover { text-decoration: underline; }
        `;
        ui.style = document.createElement("style");
        ui.style.textContent = css;
        document.head.appendChild(ui.style);

        let trackCheckboxesHTML = TRACKS.map(track => {
            const trackId = `crf-track-${track.toLowerCase().replace(/\s/g, '-')}`;
            return `<label><input type="checkbox" id="${trackId}"> ${track}</label>`;
        }).join('');

        ui.collapsibleBox = document.createElement('div');
        ui.collapsibleBox.className = 'crf-container';
        ui.collapsibleBox.innerHTML = `
            <div class="crf-header title-black"><span>Filter & Sort</span><span class="arrow">▼</span></div>
            <div class="crf-content cont-black">
                <div class="crf-options">
                    <label><input type="checkbox" id="crf-hide-passworded"> Hide Races with Passwords</label>
                    <label><input type="checkbox" id="crf-only-any"> Only "Any Car"</label>
                    <label><input type="checkbox" id="crf-hide-full"> Hide Full Races</label>
                    <div class="crf-filter-row crf-track-filter">
                        <label for="crf-track-filter-enabled"><input type="checkbox" id="crf-track-filter-enabled"> Track Filter</label>
                        <div class="crf-inputs-group"><button id="crf-track-select-btn" class="torn-btn">Select Tracks</button></div>
                        <div id="crf-track-dropdown">${trackCheckboxesHTML}</div>
                    </div>
                    <div class="crf-filter-row">
                        <label for="crf-time-enabled"><input type="checkbox" id="crf-time-enabled"> Start Time Lower Than</label>
                        <div class="crf-inputs-group"><input type="number" id="crf-max-time-h" placeholder="h" min="0"><input type="number" id="crf-max-time-m" placeholder="m" min="0" max="59"></div>
                    </div>
                    <div class="crf-filter-row">
                        <label for="crf-laps-enabled"><input type="checkbox" id="crf-laps-enabled"> Lap Count</label>
                        <div class="crf-inputs-group"><input type="number" id="crf-min-laps" placeholder="Min" min="0"><input type="number" id="crf-max-laps" placeholder="Max" min="0"></div>
                    </div>
                    <div class="crf-filter-row">
                        <label for="crf-fee-enabled"><input type="checkbox" id="crf-fee-enabled"> Join Fee</label>
                        <div class="crf-inputs-group crf-fee-inputs"><input type="number" id="crf-min-fee" placeholder="Min" min="0"><input type="number" id="crf-max-fee" placeholder="Max" min="0"></div>
                    </div>
                </div>
            </div>
        `;

        const header = ui.collapsibleBox.querySelector('.crf-header');
        const content = ui.collapsibleBox.querySelector('.crf-content');
        header.addEventListener('click', () => { header.classList.toggle('open'); content.classList.toggle('visible'); });

        const trackSelectBtn = ui.collapsibleBox.querySelector('#crf-track-select-btn');
        const trackDropdown = ui.collapsibleBox.querySelector('#crf-track-dropdown');
        trackSelectBtn.addEventListener('click', (e) => { e.stopPropagation(); trackDropdown.style.display = trackDropdown.style.display === 'block' ? 'none' : 'block'; });
        document.addEventListener('click', (e) => { if (!trackDropdown.contains(e.target) && e.target !== trackSelectBtn) trackDropdown.style.display = 'none'; });

        const controls = [
            { id: 'crf-hide-passworded', key: STORAGE_KEYS.HIDE_PW, type: 'checkbox' },
            { id: 'crf-only-any', key: STORAGE_KEYS.ONLY_ANY, type: 'checkbox' },
            { id: 'crf-hide-full', key: STORAGE_KEYS.HIDE_FULL, type: 'checkbox' },
            { id: 'crf-fee-enabled', key: STORAGE_KEYS.FEE_ENABLED, type: 'checkbox', group: ['crf-min-fee', 'crf-max-fee'] },
            { id: 'crf-min-fee', key: STORAGE_KEYS.FEE_MIN, type: 'input' },
            { id: 'crf-max-fee', key: STORAGE_KEYS.FEE_MAX, type: 'input' },
            { id: 'crf-laps-enabled', key: STORAGE_KEYS.LAPS_ENABLED, type: 'checkbox', group: ['crf-min-laps', 'crf-max-laps'] },
            { id: 'crf-min-laps', key: STORAGE_KEYS.LAPS_MIN, type: 'input' },
            { id: 'crf-max-laps', key: STORAGE_KEYS.LAPS_MAX, type: 'input' },
            { id: 'crf-time-enabled', key: STORAGE_KEYS.TIME_ENABLED, type: 'checkbox', group: ['crf-max-time-h', 'crf-max-time-m'] },
            { id: 'crf-max-time-h', key: STORAGE_KEYS.TIME_H, type: 'input' },
            { id: 'crf-max-time-m', key: STORAGE_KEYS.TIME_M, type: 'input' },
            { id: 'crf-track-filter-enabled', key: STORAGE_KEYS.TRACK_FILTER_ENABLED, type: 'checkbox', group: ['crf-track-select-btn'] }
        ];

        controls.forEach(control => {
            const el = ui.collapsibleBox.querySelector(`#${control.id}`);
            const toggleInputs = () => {
                if (!control.group) return;
                const isDisabled = !el.checked;
                control.group.forEach(inputId => ui.collapsibleBox.querySelector(`#${inputId}`).disabled = isDisabled);
            };

            if (control.type === 'checkbox') {
                el.checked = localStorage.getItem(control.key) === 'true';
                el.addEventListener('change', () => {
                    localStorage.setItem(control.key, el.checked);
                    toggleInputs();
                    applyFiltersAndSort();
                });
                toggleInputs();
            } else {
                el.value = localStorage.getItem(control.key) || '';
                el.addEventListener('input', () => { localStorage.setItem(control.key, el.value); applyFiltersAndSort(); });
            }
        });

        TRACKS.forEach(track => {
            const trackId = `crf-track-${track.toLowerCase().replace(/\s/g, '-')}`;
            const trackKey = `${STORAGE_KEYS.TRACK_CHECKBOX_PREFIX}${track}`;
            const checkbox = ui.collapsibleBox.querySelector(`#${trackId}`);
            checkbox.checked = localStorage.getItem(trackKey) !== 'false';
            checkbox.addEventListener('change', () => { localStorage.setItem(trackKey, checkbox.checked); applyFiltersAndSort(); });
        });
    }

    function setupSortingHeaders() {
        const raceListContainer = document.querySelector('.custom-events-wrap');
        if (!raceListContainer || raceListContainer.dataset.sortingReady) return;
        const startTimeHeader = raceListContainer.querySelector('.title .startTime');
        if (startTimeHeader) {
            startTimeHeader.setAttribute('data-sortable', 'startTime');
            startTimeHeader.addEventListener('click', handleSortClick);
            raceListContainer.dataset.sortingReady = 'true';
        }
    }

    function handleSortClick(event) {
        const key = event.currentTarget.dataset.sortable;
        if (!key) return;
        if (sortState.key === key) {
            sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
        } else {
            sortState.key = key;
            sortState.direction = 'asc';
        }
        sortRaces();
    }

    function mainLoop() {
        const activeTabLink = document.querySelector('.racing-main-wrap ul.categories li.active a.btn-action-tab');
        const onCustomRaceTab = activeTabLink && activeTabLink.getAttribute('tab-value') === 'customrace';
        const boxIsInDom = !!document.querySelector('.crf-container');

        if (onCustomRaceTab) {
            const anchorElement = document.querySelector('.content-title.m-bottom10');
            if (anchorElement && !boxIsInDom) {
                anchorElement.after(ui.collapsibleBox);
            }
            setupSortingHeaders();
            applyFiltersAndSort();
        } else {
            if (boxIsInDom) {
                ui.collapsibleBox.remove();
            }
        }
    }

    window.addEventListener('load', () => {
        console.log("CRF: Window loaded. Initializing script v0.0.33.");
        createUI();
        setInterval(mainLoop, 250);
    });

})();