Torn Crimes Card Skimming Extended

Sorts all installed card skimmers by location, time installed, score or cards skimmed. Adds card/hour stat. Remembers your choice.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Crimes Card Skimming Extended
// @namespace    https://github.com/SOLiNARY
// @version      0.5.6
// @description  Sorts all installed card skimmers by location, time installed, score or cards skimmed. Adds card/hour stat. Remembers your choice.
// @author       Ramin Quluzade, Silmaril [2665762]
// @license      MIT License
// @match        https://www.torn.com/loader.php?sid=crimes*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        none
// ==/UserScript==
 
(function() {
    'use strict';
 
    const sortBy = {
        "Location": 10,
        "TimeInstalled": 20,
        "CardsSkimmed": 30,
        "Score": 40
    };
    const sortDirection = {
        "Ascending": 1,
        "Descending": -1
    }
 
    const viewPortWidthPx = window.innerWidth;
    const isMobileView = viewPortWidthPx <= 784;
    let currentSortBy = localStorage.getItem("silmaril-torn-crimes-card-skimming-sorting-by") ?? sortBy.Location;
    currentSortBy = parseInt(currentSortBy);
    let currentSortDirection = localStorage.getItem("silmaril-torn-crimes-card-skimming-sorting-direction") ?? sortDirection.Descending;
    currentSortDirection = parseInt(currentSortDirection);
 
    const targetNode = document.querySelector("div.crimes-app");
    const config = { childList: true, subtree: true };
 
    const observer = new MutationObserver((mutationsList, observer) => {
        const divs = document.querySelectorAll("div[class*=currentCrime___]");
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.target.className == 'crime-root cardskimming-root') {
                divs.forEach((div) => {
                    div.addEventListener("click", function (event) {
                        if (event.target.matches("div[class*=topSection___] div[class*=crimeBanner___] div[class*=crimeSliderArrowButtons___] button[class*=arrowButton___]")) {
                            observer.observe(targetNode, config);
                        }
                        if (event.target.matches("div[class*=crimeOptionGroup___]:not([class*=firstGroup___]) div.silmaril-crimes-card-skimming-sorting")) {
                            let sortName = event.target.getAttribute("data-sort-name");
                            let newSortBy = sortBy[sortName];
                            let newSortDirection = newSortBy === currentSortBy ? currentSortDirection * -1 : currentSortDirection;
                            sortChildElements(mutation.target, newSortBy, newSortDirection);
                            currentSortBy = newSortBy;
                            currentSortDirection = newSortDirection;
                            localStorage.setItem("silmaril-torn-crimes-card-skimming-sorting-by", newSortBy);
                            localStorage.setItem("silmaril-torn-crimes-card-skimming-sorting-direction", newSortDirection);
                        }
                    });
                });
 
                addHeader(mutation.target);
                sortChildElements(mutation.target, currentSortBy, currentSortDirection);
                observer.disconnect();
                break;
            }
        }
    });
 
    observer.observe(targetNode, config);
 
    // Function to sort child elements
    function sortChildElements(element, sortByProperty, sortDirection) {
        const parentElement = element.querySelector('[class*=crimeOptionGroup___]:not([class*=firstGroup___])');
        const childElements = Array.from(parentElement.querySelectorAll('[class*=crimeOption___]:not(.silmaril-card-skimming-header)'));
        let locationStats = {};
 
        // Append sorted elements back to the parent element
        childElements.forEach(element => {
            let cardsSkimmed = element.querySelector('[class*=crimeOptionSection___][class*=statusSection___] [class*=statusCards___]').textContent;
            let hoursElapsed = parseVerbalTimestamp(element.querySelector(`[class*=crimeOptionSection___]${isMobileView ? '[class*=tabletMainSection___] div[class*=timeActive___]' : '[class*=timeSection___]'}`).textContent) / 3600;
            let locationDiv = element.querySelector('[class*=crimeOptionSection___][class*=flexGrow___]');
            let location = null;
 
            let locationText = locationDiv.innerText;
            let newLineIdx = locationText.indexOf('\n');
            if (newLineIdx >= 0){
                location = locationText.substring(0, newLineIdx);
            } else {
                location = locationText;
            }
 
            if (element.querySelector('div.stats') === null) {
                if (isMobileView){
                    const statsDivNew = document.createElement('div');
                    statsDivNew.className = 'stats';
                    statsDivNew.style.fontSize = '.6rem';
                    locationDiv.appendChild(statsDivNew);
                } else {
                    const delimiter = document.createElement('div');
                    delimiter.className = 'sectionDelimiter___NpsSC';
                    const statsDivNew = document.createElement('div');
                    statsDivNew.className = 'crimeOptionSection___hslpu stats';
                    locationDiv.outerHTML += delimiter.outerHTML + statsDivNew.outerHTML;
                }
            }
 
            let statsDiv = element.querySelector('div.stats');
            let statsScore = (cardsSkimmed / hoursElapsed).toFixed(2);
            statsDiv.textContent = `${statsScore} card/hour`;
            element.setAttribute('data-score', parseFloat(statsScore).toFixed(2));
 
            // Add stats to the locationStats object
            if (!locationStats[location]) {
                locationStats[location] = {
                    totalScore: 0,
                    totalCount: 0
                };
            }
 
            locationStats[location].totalScore += parseFloat(statsScore);
            locationStats[location].totalCount++;
        });
 
        // Sort card skimmers based on the filter
        switch (sortByProperty){
            case sortBy.Location:
                childElements.sort((a, b) => {
                    const aValue = a.querySelector('[class*=crimeOptionSection___][class*=flexGrow___]').textContent;
                    const bValue = b.querySelector('[class*=crimeOptionSection___][class*=flexGrow___]').textContent;
                    return aValue.localeCompare(bValue) * sortDirection;
                });
                break;
            case sortBy.TimeInstalled:
                if (isMobileView) {
                    childElements.sort((a, b) => {
                        const aValue = parseVerbalTimestamp(a.querySelector('[class*=crimeOptionSection___][class*=tabletMainSection___] div[class*=timeActive___]').textContent);
                        const bValue = parseVerbalTimestamp(b.querySelector('[class*=crimeOptionSection___][class*=tabletMainSection___] div[class*=timeActive___]').textContent);
                        return (aValue < bValue ? -1 : aValue > bValue ? 1 : 0) * sortDirection;
                    });
                } else {
                    childElements.sort((a, b) => {
                        const aValue = parseVerbalTimestamp(a.querySelector('[class*=crimeOptionSection___][class*=timeSection___]').textContent);
                        const bValue = parseVerbalTimestamp(b.querySelector('[class*=crimeOptionSection___][class*=timeSection___]').textContent);
                        return (aValue < bValue ? -1 : aValue > bValue ? 1 : 0) * sortDirection;
                    });
                }
                break;
            case sortBy.Score:
                childElements.sort((a, b) => {
                    const aValue = a.getAttribute('data-score');
                    const bValue = b.getAttribute('data-score');
                    return aValue.localeCompare(bValue, undefined, {'numeric': true}) * sortDirection;
                });
                break;
            case sortBy.CardsSkimmed:
                childElements.sort((a, b) => {
                    const aValue = a.querySelector('[class*=crimeOptionSection___][class*=statusSection___] [class*=statusCards___]').textContent;
                    const bValue = b.querySelector('[class*=crimeOptionSection___][class*=statusSection___] [class*=statusCards___]').textContent;
                    return aValue.localeCompare(bValue, undefined, {'numeric': true}) * sortDirection;
                });
                break;
            default:
                console.error("[TornCrimesCardSkimmingSorting] Unexpected sort values!", sortByProperty, sortDirection);
                break;
        }
 
        childElements.forEach(element => {
            parentElement.appendChild(element);
        });
 
        let locationsDropdown = document.querySelector('div[class*=locationSelectSection___] ul');
 
        // Calculate the average stat score for each location and append it to dropdown option
        for (let location in locationStats) {
            const averageScore = (locationStats[location].totalScore / locationStats[location].totalCount).toFixed(2);
            let option = locationsDropdown.querySelector(`li#option-${location.replace(' ', '-')}`);
            let stats = option.querySelector('p.stats') ?? addStatsBlockToDropdownOption(option);
            stats.textContent = ` ${averageScore} c/h`;
        }
 
        let totalStatsDiv = document.querySelector("div[class*=currentCrime___] div[class*=titleBar___] div.total-stats") ?? addTotalStats();
 
        // Calculate the overall average stat score
        let overallTotalScore = 0;
        let overallTotalCount = 0;
 
        for (let location in locationStats) {
            overallTotalScore += locationStats[location].totalScore;
            overallTotalCount += locationStats[location].totalCount;
        }
 
        const overallScore = overallTotalScore.toFixed(2);
        totalStatsDiv.textContent = isMobileView ? `${overallScore} c/h - ${overallTotalCount}/20 skimmers` : `${overallScore} card/hour with ${overallTotalCount}/20 skimmers`;
    }
 
    function addTotalStats() {
        const statBlock = document.createElement('div');
        statBlock.className = 'total-stats';
        let crimeTitle = document.querySelector("div[class*=currentCrime___] div[class*=titleBar___] div[class*=title___]");
        crimeTitle.parentNode.insertBefore(statBlock, crimeTitle.nextSibling);
        return statBlock;
    }
 
    function addStatsBlockToDropdownOption(element) {
        const statBlock = document.createElement('p');
        statBlock.className = 'stats';
        element.appendChild(statBlock);
        return statBlock;
    }
 
    function addHeader(element) {
        const parentElement = element.querySelector('[class*=crimeOptionGroup___]:not([class*=firstGroup___])');
        let header = parentElement.querySelector('[class*=crimeOption___]').cloneNode(true);
        header.classList.add("silmaril-card-skimming-header");
        let headerDiv = header.querySelector('[class*=sections___]');
        headerDiv.style.height = "25px";
        let imageDiv = header.querySelector('[class*=crimeOptionImage___]');
        imageDiv.style = "display: flex;justify-content: center;align-items: center;flex-direction: row;height: 25px;";
        imageDiv.innerText = "Sort by";
        let nameDiv = header.querySelector('[class*=crimeOptionSection___][class*=flexGrow___]');
        nameDiv.style.cursor = "pointer";
        nameDiv.classList.add("silmaril-crimes-card-skimming-sorting");
        nameDiv.classList.add("silmaril-crimes-card-skimming-sorting-location");
        nameDiv.setAttribute("data-sort-name", "Location");
        nameDiv.innerHTML = "Location ⇧⇩";
        let scoreDiv = nameDiv.cloneNode(true);
        scoreDiv.classList.remove("silmaril-crimes-card-skimming-sorting-location");
        scoreDiv.classList.add("silmaril-crimes-card-skimming-sorting-score");
        scoreDiv.setAttribute("data-sort-name", "Score");
        scoreDiv.innerText = "Score ⇧⇩";
 
        let delimiter = document.createElement("div");
        delimiter.className = "sectionDelimiter___NpsSC";
        if (isMobileView) {
            nameDiv.parentNode.insertBefore(delimiter, nameDiv.nextSibling);
            let timeDiv = nameDiv.cloneNode(true);
            timeDiv.classList.remove("silmaril-crimes-card-skimming-sorting-location");
            timeDiv.classList.add("silmaril-crimes-card-skimming-sorting-time-installed");
            timeDiv.setAttribute("data-sort-name", "TimeInstalled");
            timeDiv.innerText = "Time ⇧⇩";
            delimiter.parentNode.insertBefore(timeDiv, delimiter.nextSibling);
            timeDiv.parentNode.insertBefore(delimiter, timeDiv.nextSibling);
            delimiter.parentNode.insertBefore(scoreDiv, delimiter.nextSibling);
        } else {
            let timeDiv = header.querySelector('[class*=crimeOptionSection___][class*=timeSection___]');
            timeDiv.style.cursor = "pointer";
            timeDiv.classList.add("silmaril-crimes-card-skimming-sorting");
            timeDiv.classList.add("silmaril-crimes-card-skimming-sorting-time-installed");
            timeDiv.setAttribute("data-sort-name", "TimeInstalled");
            timeDiv.innerText = "Time installed ⇧⇩";
            scoreDiv.style.justifyContent = 'space-around';
            scoreDiv.style.width = '13px';
            nameDiv.parentNode.insertBefore(delimiter, nameDiv.nextSibling);
            delimiter.parentNode.insertBefore(scoreDiv, delimiter.nextSibling);
        }
 
        let cardsDiv = header.querySelector('[class*=crimeOptionSection___][class*=statusSection___]');
        cardsDiv.style.cursor = "pointer";
        cardsDiv.classList.add("silmaril-crimes-card-skimming-sorting");
        cardsDiv.classList.add("silmaril-crimes-card-skimming-sorting-cards-skimmed");
        cardsDiv.setAttribute("data-sort-name", "CardsSkimmed");
        cardsDiv.innerText = isMobileView ? "Cards ⇧⇩" : "Cards skimmed ⇧⇩";
 
        header.querySelector(`[class*=commitButtonSection___] ${isMobileView ? '' : 'button'}`).remove();
        parentElement.appendChild(header);
    }
 
    function parseVerbalTimestamp(verbalTimestamp) {
        const timeUnits = {
            second: 1,
            seconds: 1,
            minute: 60,
            minutes: 60,
            hour: 3600,
            hours: 3600,
            day: 86400,
            days: 86400,
            week: 604800,
            weeks: 604800
        };
 
        const regex = /(\d+)\s+(\w+)/g;
        let totalSeconds = 0;
 
        let match;
        while ((match = regex.exec(verbalTimestamp))) {
            const [, value, unit] = match;
            if (timeUnits.hasOwnProperty(unit)) {
                totalSeconds += parseInt(value) * timeUnits[unit];
            }
        }
 
        return totalSeconds;
    }
})();