Custom Race Filter

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==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);
    });

})();