F95zone Development Time Tracker & Sorter

Calculates and shows the development duration of games on f95zone.to/sam/latest_alpha/* page with a sorting feature.

// ==UserScript==
// @name             F95zone Development Time Tracker & Sorter
// @namespace        http://tampermonkey.net/
// @version          1.0
// @description      Calculates and shows the development duration of games on f95zone.to/sam/latest_alpha/* page with a sorting feature. 
// @author           0wn3dbot
// @license          MIT
// @match            https://f95zone.to/sam/latest_alpha/*
// @grant            GM_xmlhttpRequest
// @grant            GM_addStyle
// @grant            GM_setValue
// @grant            GM_getValue
// @connect          f95zone.to
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const CONFIG = {
        filterStorageKey: 'devTimeFilterSettings',
        sortStorageKey: 'devTimeSortEnabled',
        requestBatchSize: 5, // Number of requests to send simultaneously
        creationDateStoragePrefix: 'threadCreationDate_' // Prefix for keys in GM storage
    };

    // --- Styles ---
    GM_addStyle(`
        /* Main overlay styles */
        .resource-tile {
            position: relative;
            overflow: hidden;
            transition: transform 0.3s ease, opacity 0.3s ease;
        }
        .resource-tile_thumb-wrap {
            position: relative;
        }
        .dev-time-overlay {
            position: absolute;
            top: 0;
            left: 0;
            background-color: rgba(15, 15, 15, 0.8);
            color: #e0e0e0;
            padding: 3px 6px;
            font-size: 11px;
            font-weight: 500;
            border-radius: 0 8px 0 0;
            z-index: 10;
            font-family: 'Lato', 'Open Sans', sans-serif;
            pointer-events: none;
            line-height: 1.2;
            box-shadow: 1px 1px 3px rgba(0,0,0,0.5);
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
            border-right: 1px solid rgba(255, 255, 255, 0.1);
            text-shadow: 1px 1px 1px rgba(0,0,0,0.7);
        }

        /* Toolbar */
        .dev-tools-panel {
            background: #2a2a2a;
            border-radius: 8px;
            padding: 12px 15px;
            margin: 0 auto 20px;
            max-width: 1200px;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 15px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            border: 1px solid #3a3a3a;
        }

        .dev-progress-container {
            flex: 1;
            min-width: 200px;
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .dev-progress-bar {
            flex: 1;
            height: 8px;
            background: #3a3a3a;
            border-radius: 4px;
            overflow: hidden;
        }

        .dev-progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #4a8bfc, #2a65c5);
            width: 0%;
            transition: width 0.3s ease;
        }

        .dev-progress-text {
            font-size: 12px;
            color: #aaa;
            min-width: 80px;
            text-align: center;
            font-family: 'Lato', sans-serif;
        }

        .dev-tools-btn {
            background: #3a3a3a;
            color: #ddd;
            border: none;
            padding: 6px 12px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 12px;
            font-family: 'Lato', sans-serif;
            transition: all 0.2s ease;
            display: flex;
            align-items: center;
            gap: 5px;
        }

        .dev-tools-btn:hover {
            background: #4a4a4a;
            color: #fff;
        }

        .dev-tools-btn.active {
            background: #4a8bfc;
            color: #fff;
        }

        .dev-tools-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }

        .dev-filter-container {
            display: flex;
            align-items: center;
            gap: 5px;
            background: rgba(0,0,0,0.2);
            padding: 5px 8px;
            border-radius: 4px;
            border: 1px solid #3a3a3a;
        }

        .dev-filter-input {
            width: 30px;
            background: #3a3a3a;
            border: 1px solid #4a4a4a;
            color: #ddd;
            padding: 3px;
            text-align: center;
            border-radius: 3px;
            font-size: 11px;
        }

        .dev-filter-separator {
            color: #666;
            font-size: 12px;
        }

        /* Animations */
        @keyframes pulse {
            0% { opacity: 0.6;
            }
            50% { opacity: 1;
            }
            100% { opacity: 0.6;
            }
        }

        .dev-processing {
            animation: pulse 1.5s infinite;
        }

        .dev-hidden {
            transform: scale(0.95);
            opacity: 0.3;
            pointer-events: none;
        }
    `);

    // --- Helper functions ---

    /**
     * Converts development time string to milliseconds
     * @param {string} timeStr - Time string (e.g., "1y, 2m, 3w, 4d")
     * @returns {number} Time in milliseconds
     */
    function timeStringToMs(timeStr) {
        if (!timeStr || timeStr === "N/A") return 0;
        const msPerDay = 1000 * 60 * 60 * 24;
        const msPerWeek = msPerDay * 7;
        const msPerMonth = msPerDay * (365.25 / 12);
        const msPerYear = msPerDay * 365.25;

        let totalMs = 0;
        const parts = timeStr.split(',').map(p => p.trim());

        parts.forEach(part => {
            if (part.includes('y')) {
                const years = parseInt(part.replace('y', '')) || 0;
                totalMs += years * msPerYear;
            } else if (part.includes('m')) {
                const months = parseInt(part.replace('m', '')) || 0;
                totalMs += months * msPerMonth;
            } else if (part.includes('w')) {
                const weeks = parseInt(part.replace('w', '')) || 0;
                totalMs += weeks * msPerWeek;
            } else if (part.includes('d')) {
                const days = parseInt(part.replace('d', '')) || 0;
                totalMs += days * msPerDay;
            } else if (part === "<1d") {
                totalMs += msPerDay * 0.5; // Half a day for "<1d"
            }
        });

        return totalMs;
    }

    /**
     * Calculates the difference between two dates and formats it.
     * @param {Date} startDate - Start date (thread creation)
     * @param {Date} endDate - End date (last update)
     * @returns {string} Formatted difference string (e.g., "1y, 2m, 3w, 1d")
     */
    function calculateDateDifference(startDate, endDate) {
        if (!startDate || !endDate) {
            return "N/A";
        }

        if (startDate > endDate) {
            return ">1d"; // Display ">1d" if creation date is in the future
        }

        let diff = Math.abs(endDate - startDate);
        const msPerDay = 1000 * 60 * 60 * 24;
        const msPerWeek = msPerDay * 7;
        const msPerMonth = msPerDay * (365.25 / 12);
        const msPerYear = msPerDay * 365.25;

        const years = Math.floor(diff / msPerYear);
        diff -= years * msPerYear;

        const months = Math.floor(diff / msPerMonth);
        diff -= months * msPerMonth;
        const weeks = Math.floor(diff / msPerWeek);
        diff -= weeks * msPerWeek;

        const days = Math.floor(diff / msPerDay);
        let result = [];
        if (years > 0) result.push(`${years}y`);
        if (months > 0) result.push(`${months}m`);
        if (weeks > 0) result.push(`${weeks}w`);
        if (days > 0 && (result.length === 0 || years > 0 || months > 0 || weeks > 0)) {
            result.push(`${days}d`);
        }
        if (result.length === 0 && Math.abs(endDate - startDate) > 0) {
            return "&lt;1d";
        }

        return result.join(', ') || "N/A";
    }

    /**
     * Determines the approximate last update date based on the text in the tile.
     * @param {Element} timeElement - Span element inside .resource-tile_info-meta_time
     * @returns {Date|null} Calculated date or null
     */
    function getLastUpdateDate(timeElement) {
        const now = new Date();
        if (!timeElement) return now;

        const timeValue = parseInt(timeElement.textContent.trim(), 10);
        const timeClass = timeElement.className;
        if (timeClass.includes('tile-date_mins')) {
            now.setMinutes(now.getMinutes() - (isNaN(timeValue) ? 0 : timeValue));
        } else if (timeClass.includes('tile-date_hrs')) {
            now.setHours(now.getHours() - (isNaN(timeValue) ? 0 : timeValue));
        } else if (timeClass.includes('tile-date_yesterday')) {
            now.setDate(now.getDate() - 1);
        } else if (timeClass.includes('tile-date_days')) {
            now.setDate(now.getDate() - (isNaN(timeValue) ? 0 : timeValue));
        } else if (timeClass.includes('tile-date_week')) {
            now.setDate(now.getDate() - (isNaN(timeValue) ? 0 : timeValue) * 7);
        } else if (timeClass.includes('tile-date_month')) {
            now.setMonth(now.getMonth() - (isNaN(timeValue) ? 0 : timeValue));
        } else if (timeClass.includes('tile-date_years')) {
            now.setFullYear(now.getFullYear() - (isNaN(timeValue) ? 0 : timeValue));
        } else {
            return new Date();
        }
        return now;
    }

    /**
     * Finds the thread creation date on the page
     * @param {Document} doc - DOM of the thread page
     * @returns {Date|null} Creation date or null
     */
    function findThreadCreationDate(doc) {
        // Try to find the standard time element
        let threadTimeElement = doc.querySelector('time.u-dt[datetime]');
        if (threadTimeElement) {
            const creationDateTimeString = threadTimeElement.getAttribute('datetime');
            const creationDate = new Date(creationDateTimeString);
            return isNaN(creationDate) ? null : creationDate;
        }

        // Alternative for pages with a different format
        const startDateElement = doc.querySelector('i.fa-clock')?.nextElementSibling?.nextElementSibling;
        if (startDateElement) {
            const timeElement = startDateElement.querySelector('time.u-dt[datetime]');
            if (timeElement) {
                const creationDateTimeString = timeElement.getAttribute('datetime');
                const creationDate = new Date(creationDateTimeString);
                return isNaN(creationDate) ? null : creationDate;
            }
        }

        return null;
    }

    // --- Class for managing tools ---
    class DevTools {
        constructor() {
            this.totalTiles = 0;
            this.processedTiles = 0;
            this.tilesData = new Map();
            this.sortActive = GM_getValue(CONFIG.sortStorageKey, false);
            this.filterSettings = GM_getValue(CONFIG.filterStorageKey, { min: {}, max: {} });
            this.processingQueue = [];
            this.isProcessing = false;
            this.collectionStarted = false;

            this.initPanel();
            this.updateProgress(); // Initialize progress to display 0/0
        }

        initPanel() {
            // Create a container for the toolbar
            const noticesContainer = document.querySelector('.notices--block')?.parentElement ||
            document.body;
            this.panelElement = document.createElement('div');
            this.panelElement.className = 'dev-tools-panel';

            // "Start Collection" button
            this.startButton = document.createElement('button');
            this.startButton.className = 'dev-tools-btn';
            this.startButton.innerHTML = '<i class="fas fa-play"></i> Start Collection';
            this.startButton.addEventListener('click', () => this.startCollection());
            // Progress container (hidden until collection starts)
            this.progressContainer = document.createElement('div');
            this.progressContainer.className = 'dev-progress-container';
            this.progressContainer.style.display = 'none';

            this.progressBar = document.createElement('div');
            this.progressBar.className = 'dev-progress-bar';

            this.progressFill = document.createElement('div');
            this.progressFill.className = 'dev-progress-fill';
            this.progressBar.appendChild(this.progressFill);
            this.progressText = document.createElement('span');
            this.progressText.className = 'dev-progress-text';
            this.progressText.textContent = '0/0';

            this.progressContainer.appendChild(this.progressBar);
            this.progressContainer.appendChild(this.progressText);
            // Sort button
            this.sortBtn = document.createElement('button');
            this.sortBtn.className = `dev-tools-btn ${this.sortActive ? 'active' : ''}`;
            this.sortBtn.innerHTML = '<i class="fas fa-sort-amount-down"></i> Sort';
            this.sortBtn.disabled = true;
            this.sortBtn.addEventListener('click', () => this.toggleSort());

            // Assembling the panel
            this.panelElement.append(
                this.startButton,
                this.progressContainer,
                this.sortBtn
            );
            // Insert the panel after notifications or at the beginning of the body
            noticesContainer.insertBefore(this.panelElement, noticesContainer.firstChild);
        }

        startCollection() {
            if (!this.collectionStarted) {
                this.collectionStarted = true;
                this.startButton.style.display = 'none';
                this.progressContainer.style.display = 'flex';
                this.totalTiles = document.querySelectorAll('.resource-tile').length; // Set the total number of tiles once
                this.updateProgress();
                this.processAllTiles();
                setTimeout(() => this.observeDOM(), 10000); // Start MutationObserver after 10 seconds
                this.startPeriodicCheck();
            }
        }

        updateProgress() {
            this.progressText.textContent = `${this.processedTiles}/${this.totalTiles}`;
            const progressPercent = this.totalTiles > 0 ? (this.processedTiles / this.totalTiles) * 100 : 0;
            this.progressFill.style.width = `${progressPercent}%`;
            // Activate buttons if all tiles are processed
            if (this.processedTiles > 0 && this.processedTiles === this.totalTiles) {
                this.sortBtn.disabled = false;

                // Apply saved sorting
                if (this.sortActive) {
                    this.applySort();
                }
            }
        }

        toggleSort() {
            this.sortActive = !this.sortActive;
            this.sortBtn.classList.toggle('active', this.sortActive);
            GM_setValue(CONFIG.sortStorageKey, this.sortActive);

            if (this.sortActive) {
                this.applySort();
            } else {
                this.resetSort();
            }
        }

        applySort() {
            const container = document.querySelector('#latest-page_items-wrap_inner');
            if (!container) return;

            // Create an array of tiles for sorting
            const tilesArray = Array.from(this.tilesData.keys());
            // Sort by development time (descending), placing ">1d" at the end
            tilesArray.sort((a, b) => {
                const aData = this.tilesData.get(a);
                const bData = this.tilesData.get(b);

                if (aData.devTime === ">1d" && bData.devTime !== ">1d") {
                    return 1;
                }
                if (bData.devTime === ">1d" && aData.devTime !== ">1d") {
                    return -1;
                }

                const aMs = timeStringToMs(aData.devTime);
                const bMs = timeStringToMs(bData.devTime);

                return bMs - aMs;
            });
            // Rearrange tiles in the DOM
            tilesArray.forEach(tile => {
                container.appendChild(tile);
            });
        }

        resetSort() {
            const container = document.querySelector('#latest-page_items-wrap_inner');
            if (!container) return;

            // Create an array of tiles with their original positions
            const tilesArray = Array.from(this.tilesData.keys()).map(tile => ({
                tile,
                originalOrder: parseInt(tile.dataset.originalOrder || '0')
            }));
            // Sort by original order
            tilesArray.sort((a, b) => a.originalOrder - b.originalOrder);
            // Rearrange tiles in the DOM
            tilesArray.forEach(({tile}) => {
                container.appendChild(tile);
            });
        }

        addTileToQueue(tile, threadUrl, lastUpdateDate) {
            if (!tile.dataset.devTimeProcessed) {
                tile.dataset.devTimeProcessed = 'pending';
                this.processingQueue.push({ tile, threadUrl, lastUpdateDate });
                this.updateProgress(); // Update progress to show the total count
                if (!this.isProcessing) {
                    this.processQueue();
                }
            }
        }

        processQueue() {
            if (this.processingQueue.length === 0) {
                this.isProcessing = false;
                return;
            }

            this.isProcessing = true;
            const batch = this.processingQueue.splice(0, CONFIG.requestBatchSize);
            const requests = batch.map(({ tile, threadUrl, lastUpdateDate }) => {
                const threadIdMatch = threadUrl.match(/threads\/(\d+)/);
                const threadId = threadIdMatch ? threadIdMatch[1] : null;
                const creationDateKey = threadId ? `${CONFIG.creationDateStoragePrefix}${threadId}` : null;

                return new Promise((resolve, reject) => {
                    if (creationDateKey) {
                        const storedCreationDate = GM_getValue(creationDateKey);
                        if (storedCreationDate) {
                            const creationDate = new Date(storedCreationDate);
                            const diffString = calculateDateDifference(creationDate, lastUpdateDate);
                            const thumbWrap = tile.querySelector('.resource-tile_thumb-wrap');
                            if (thumbWrap) {
                                const overlay = document.createElement('div');
                                overlay.className = 'dev-time-overlay';
                                overlay.innerHTML = `Dev Time: ${diffString}`;
                                thumbWrap.appendChild(overlay);
                            }
                            devTools.tilesData.set(tile, { devTime: diffString, lastUpdateDate, creationDate });
                            tile.dataset.devTimeProcessed = 'true';
                            devTools.processedTiles++;
                            devTools.updateProgress();
                            resolve();
                            return;
                        }
                    }

                    GM_xmlhttpRequest({
                        method: "GET",
                        url: threadUrl,
                        onload: function(response) {
                            if (response.status >= 200 && response.status < 300) {
                                try {
                                    const parser = new DOMParser();
                                    const doc = parser.parseFromString(response.responseText, "text/html");
                                    const creationDate = findThreadCreationDate(doc);

                                    if (creationDate) {
                                        if (creationDateKey) {
                                            GM_setValue(creationDateKey, creationDate.toISOString());
                                        }
                                        const diffString = calculateDateDifference(creationDate, lastUpdateDate);
                                        const thumbWrap = tile.querySelector('.resource-tile_thumb-wrap');
                                        if (thumbWrap) {
                                            const overlay = document.createElement('div');
                                            overlay.className = 'dev-time-overlay';
                                            overlay.innerHTML = `Dev Time: ${diffString}`;
                                            thumbWrap.appendChild(overlay);
                                        }
                                        devTools.tilesData.set(tile, { devTime: diffString, lastUpdateDate, creationDate });
                                        tile.dataset.devTimeProcessed = 'true';
                                        devTools.processedTiles++;
                                        devTools.updateProgress();
                                        resolve();
                                    } else {
                                        console.warn(`Could not find creation date on: ${threadUrl}`);
                                        devTools.tilesData.set(tile, { devTime: "N/A", lastUpdateDate });
                                        tile.dataset.devTimeProcessed = 'failed';
                                        devTools.processedTiles++;
                                        devTools.updateProgress();
                                        resolve();
                                    }
                                } catch (e) {
                                    console.error(`Error parsing response for ${threadUrl}:`, e);
                                    devTools.tilesData.set(tile, { devTime: "N/A", lastUpdateDate });
                                    tile.dataset.devTimeProcessed = 'error';
                                    devTools.processedTiles++;
                                    devTools.updateProgress();
                                    resolve();
                                }
                            } else {
                                console.error(`Failed to fetch ${threadUrl}. Status: ${response.status}`);
                                devTools.tilesData.set(tile, { devTime: "N/A", lastUpdateDate });
                                tile.dataset.devTimeProcessed = 'failed';
                                devTools.processedTiles++;
                                devTools.updateProgress();
                                resolve();
                            }
                        },
                        onerror: function(error) {
                            console.error(`Network error fetching ${threadUrl}:`, error);
                            devTools.tilesData.set(tile, { devTime: "N/A", lastUpdateDate });
                            tile.dataset.devTimeProcessed = 'error';
                            devTools.processedTiles++;
                            devTools.updateProgress();
                            resolve();
                        }
                    });
                });
            });

            Promise.all(requests).then(() => {
                setTimeout(() => this.processQueue(), 200); // Small delay between batches
            });
        }

        processAllTiles() {
            const tiles = document.querySelectorAll('.resource-tile:not([data-dev-time-processed])');
            // totalTiles is set in startCollection once
            tiles.forEach(tile => {
                if (!tile.dataset.originalOrder) {
                    tile.dataset.originalOrder = Array.from(tile.parentNode.children).indexOf(tile);
                }
                const linkElement = tile.querySelector('a.resource-tile_link');
                const timeMetaElement = tile.querySelector('.resource-tile_info-meta_time span');

                if (!linkElement) {
                    console.warn('Could not find link for tile:', tile);
                    return;
                }

                const threadUrl = linkElement.href;
                const lastUpdateDate = getLastUpdateDate(timeMetaElement);

                const threadIdMatch = threadUrl.match(/threads\/(\d+)/);
                const threadId = threadIdMatch ? threadIdMatch[1] : null;
                const creationDateKey = threadId ? `${CONFIG.creationDateStoragePrefix}${threadId}` : null;

                if (creationDateKey && GM_getValue(creationDateKey)) {
                    const storedCreationDate = GM_getValue(creationDateKey);
                    const creationDate = new Date(storedCreationDate);
                    const diffString = calculateDateDifference(creationDate, lastUpdateDate);
                    const thumbWrap = tile.querySelector('.resource-tile_thumb-wrap');
                    if (thumbWrap) {
                        const overlay = document.createElement('div');
                        overlay.className = 'dev-time-overlay';
                        overlay.innerHTML = `Dev Time: ${diffString}`;
                        thumbWrap.appendChild(overlay);
                    }
                    devTools.tilesData.set(tile, { devTime: diffString, lastUpdateDate, creationDate });
                    tile.dataset.devTimeProcessed = 'true';
                    devTools.processedTiles++;
                    devTools.updateProgress();
                } else {
                    this.addTileToQueue(tile, threadUrl, lastUpdateDate);
                }
            });
        }

        observeDOM() {
            const observerTarget = document.querySelector('#latest-page_items-wrap_inner');
            if (observerTarget) {
                const observer = new MutationObserver(mutations => {
                    mutations.forEach(mutation => {
                        if (mutation.addedNodes && mutation.addedNodes.length > 0) {
                            mutation.addedNodes.forEach(node => {
                                if (node.nodeType === Node.ELEMENT_NODE) {
                                    if (node.matches('.resource-tile')) {
                                        this.processTile(node);
                                    } else {
                                        const newTiles = node.querySelectorAll('.resource-tile:not([data-dev-time-processed])');
                                        newTiles.forEach(this.processTile.bind(this));
                                    }
                                }
                            });
                        }
                    });
                });
                observer.observe(observerTarget, {
                    childList: true,
                    subtree: true
                });
            } else {
                console.error("Target element for MutationObserver (#latest-page_items-wrap_inner) not found.");
            }
        }

        processTile(tile) {
            if (!tile || tile.dataset.devTimeProcessed) {
                return;
            }
            if (!tile.dataset.originalOrder) {
                tile.dataset.originalOrder = Array.from(tile.parentNode.children).indexOf(tile);
            }

            const linkElement = tile.querySelector('a.resource-tile_link');
            const timeMetaElement = tile.querySelector('.resource-tile_info-meta_time span');

            if (!linkElement) {
                console.warn('Could not find link for tile:', tile);
                return;
            }

            const threadUrl = linkElement.href;
            const lastUpdateDate = getLastUpdateDate(timeMetaElement);

            const threadIdMatch = threadUrl.match(/threads\/(\d+)/);
            const threadId = threadIdMatch ? threadIdMatch[1] : null;
            const creationDateKey = threadId ? `${CONFIG.creationDateStoragePrefix}${threadId}` : null;

            if (creationDateKey && GM_getValue(creationDateKey)) {
                const storedCreationDate = GM_getValue(creationDateKey);
                const creationDate = new Date(storedCreationDate);
                const diffString = calculateDateDifference(creationDate, lastUpdateDate);
                const thumbWrap = tile.querySelector('.resource-tile_thumb-wrap');
                if (thumbWrap) {
                    const overlay = document.createElement('div');
                    overlay.className = 'dev-time-overlay';
                    overlay.innerHTML = `Dev Time: ${diffString}`;
                    thumbWrap.appendChild(overlay);
                }
                devTools.tilesData.set(tile, { devTime: diffString, lastUpdateDate, creationDate });
                tile.dataset.devTimeProcessed = 'true';
                devTools.processedTiles++;
                devTools.updateProgress();
            } else {
                this.addTileToQueue(tile, threadUrl, lastUpdateDate);
            }
        }

        startPeriodicCheck() {
            this.checkInterval = setInterval(() => {
                const unprocessed = document.querySelectorAll('.resource-tile:not([data-dev-time-processed])');
                if (unprocessed.length > 0) {
                    unprocessed.forEach(this.processTile.bind(this));
                }
            }, 1000);
        }
    }

    // --- Main logic ---
    const devTools = new DevTools();
})();