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

})();