Torn War Timer

Display live ranked war timer in the sidebar using Torn API v2, with smart polling

// ==UserScript==
// @name         Torn War Timer
// @namespace    https://www.torn.com/
// @version      1.2
// @description  Display live ranked war timer in the sidebar using Torn API v2, with smart polling
// @author       Cypher-[2641265]
// @license      MIT
// @match        https://www.torn.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
    'use strict';
    
    function getApiKey() {
        return localStorage.getItem('rw_api_key') || '';
    }

    const timerContainerID = 'warTimerSidebar';
    const LS_KEY = 'rw_last_war_info'; // localStorage key

    function getStoredWarInfo() {
        try {
            return JSON.parse(localStorage.getItem(LS_KEY)) || {};
        } catch {
            return {};
        }
    }

    function setStoredWarInfo(obj) {
        localStorage.setItem(LS_KEY, JSON.stringify(obj));
    }

    function fetchRankedWars() {
        GM_xmlhttpRequest({
            method: "GET",
            url: `https://api.torn.com/v2/faction/rankedwars?sort=DESC&key=${getApiKey()}`,
            onload: function (response) {
                let data;
                try {
                    data = JSON.parse(response.responseText);
                } catch (e) {
                    return;
                }
                if (!data?.rankedwars || !Array.isArray(data.rankedwars)) return;

                const now = Math.floor(Date.now() / 1000);

                // Find the first war with "end": 0 and "start" in the future (pending war)
                const pendingWar = data.rankedwars.find(war => war.end === 0 && war.start > now);
                if (pendingWar) {
                    setStoredWarInfo({
                        warId: pendingWar.id,
                        start: pendingWar.start,
                        nextCheck: pendingWar.start + 5 // check again 5s after start
                    });
                    const left = Math.max(0, pendingWar.start - now);
                    updateSidebar(formatTimer(left));
                    scheduleNextCheck(pendingWar.start - now + 5); // run again just after war starts
                    return;
                }

                // If no pending war, show active war timer as zeros and wait 48 hours
                const activeWar = data.rankedwars.find(war => war.end === 0 && war.start <= now);
                if (activeWar) {
                    setStoredWarInfo({
                        warId: activeWar.id,
                        start: activeWar.start,
                        nextCheck: now + 48 * 3600 // check again in 48 hours
                    });
                    updateSidebar("0d 0h 0m");
                    scheduleNextCheck(48 * 3600);
                    return;
                }

                // If no war, set next check for 6 hours later
                setStoredWarInfo({
                    warId: null,
                    start: null,
                    nextCheck: now + 6 * 3600
                });
                updateSidebar("0d 0h 0m");
                scheduleNextCheck(6 * 3600); // 6 hours
            }
        });
    }

    function formatTimer(secs, showSeconds = false) {
        if (typeof secs !== "number" || isNaN(secs) || secs < 0) return "0d 0h 0m";
        if (showSeconds) {
            const m = Math.floor(secs / 60);
            const s = secs % 60;
            return `${m}:${s.toString().padStart(2, '0')}`;
        }
        const d = Math.floor(secs / 86400);
        const h = Math.floor((secs % 86400) / 3600);
        const m = Math.floor((secs % 3600) / 60);
        let out = "";
        if (d > 0) out += `${d}d `;
        if (h > 0 || d > 0) out += `${h}h `;
        out += `${m}m`;
        return out.trim();
    }

    function showApiKeyPopup(callback) {
        if (document.getElementById('rw_api_input')) return; // Prevent multiple popups
        let popup = document.createElement('div');
        popup.style.position = 'fixed';
        popup.style.top = '50%';
        popup.style.left = '50%';
        popup.style.transform = 'translate(-50%, -50%)';
        popup.style.background = '#222';
        popup.style.color = '#fff';
        popup.style.padding = '20px';
        popup.style.border = '2px solid #888';
        popup.style.zIndex = 9999;
        popup.style.borderRadius = '8px';
        popup.innerHTML = `
            <div style="margin-bottom:10px;">Enter your Torn public API key:</div>
            <input type="text" id="rw_api_input" style="width:300px;" maxlength="16" value="${getApiKey() || ''}">
            <div style="margin-top:10px;">
                <button id="rw_api_save" style="background:#444;color:#fff;border:1px solid #aaa;padding:6px 18px;margin-right:10px;border-radius:4px;cursor:pointer;">Save</button>
                <button id="rw_api_cancel" style="background:#444;color:#fff;border:1px solid #aaa;padding:6px 18px;border-radius:4px;cursor:pointer;">Cancel</button>
            </div>
        `;
        document.body.appendChild(popup);
        document.getElementById('rw_api_input').focus();

        document.getElementById('rw_api_save').onclick = function() {
            let key = document.getElementById('rw_api_input').value.trim();
            if (key.length === 16) {
                localStorage.setItem('rw_api_key', key);
                document.body.removeChild(popup);
                if (callback) callback(key);
            } else {
                alert('Please enter a valid 16-character Torn public API key.');
            }
        };
        document.getElementById('rw_api_cancel').onclick = function() {
            document.body.removeChild(popup);
        };
    }

    function findSidebar() {
        // Only look for Torn Tools sidebar
        const element = document.querySelector('.tt-sidebar-information');
        if (element) {
            return element;
        }
        return null;
    }

    function waitForSidebar(callback, maxAttempts = 20, attempt = 1) {
        const sidebar = findSidebar();
        if (sidebar) {
            callback();
            return;
        }
        
        if (attempt >= maxAttempts) {
            return;
        }
        
        // Exponential backoff: 100ms, 200ms, 400ms, 800ms, etc.
        const delay = Math.min(100 * Math.pow(2, attempt - 1), 5000);
        setTimeout(() => waitForSidebar(callback, maxAttempts, attempt + 1), delay);
    }

    function updateSidebar(timerText) {
        // Only use Torn Tools sidebar
        let sidebar = findSidebar();
        if (!sidebar) {
            return;
        }

        let section = document.getElementById(timerContainerID);
        if (!section) {
            section = document.createElement('section');
            section.id = timerContainerID;
            section.style.order = 2;
            // Add a wrapper span for hover effect
            section.innerHTML = `
                <style>
                    #${timerContainerID}:hover #rw-reset-link { opacity: 1; pointer-events: auto; }
                    #rw-reset-link { opacity: 0; transition: opacity 0.2s; pointer-events: none; }
                </style>
                <a class="title" href="https://www.torn.com/factions.php?step=your&type=1#/war/rank" target="_blank">RW:</a>
                <span id="war-timer-value" class="countdown"></span>
                <a id="rw-reset-link" title="Reset War Timer Settings" style="margin-left:8px;cursor:pointer;color:#b48aff;text-decoration:none;font-size:16px;">⟲</a>
            `;
            
            // Only append to Torn Tools sidebar
            sidebar.appendChild(section);
        }
        const timerSpan = section.querySelector('#war-timer-value');
        const resetLink = section.querySelector('#rw-reset-link');
        if (resetLink) {
            resetLink.onclick = function(e) {
                e.preventDefault();
                if (confirm("Reset War Timer settings? This will reset your timer data.")) {
                    localStorage.removeItem('rw_last_war_info');
                    location.reload();
                }
            };
        }
        if (timerSpan) {
            if (!getApiKey()) {
                timerSpan.textContent = "Enter public key";
                timerSpan.style.cursor = "pointer";
                timerSpan.onclick = function(e) {
                    showApiKeyPopup(() => {
                        timerSpan.onclick = null;
                        runCheck();
                    });
                };
            } else {
                timerSpan.textContent = timerText;
                timerSpan.style.cursor = "";
                timerSpan.onclick = null;
            }
        }
    }

    let nextTimeout = null;
    function scheduleNextCheck(seconds) {
        if (nextTimeout) clearTimeout(nextTimeout);
        nextTimeout = setTimeout(runCheck, Math.max(1000, seconds * 1000));
    }

    function runCheck() {
        if (!getApiKey()) {
            updateSidebar("Enter public key");
            return;
        }
        const now = Math.floor(Date.now() / 1000);
        const info = getStoredWarInfo();
        if (info.nextCheck && now < info.nextCheck) {
            scheduleNextCheck(info.nextCheck - now);
            return;
        }
        fetchRankedWars();
    }

    let liveCountdownInterval = null;

    function startLiveCountdown(secsLeft) {
        clearInterval(liveCountdownInterval);
        function tick() {
            if (secsLeft <= 0) {
                clearInterval(liveCountdownInterval);
                updateSidebar("0d 0h 0m");
                runCheck();
                return;
            }
            updateSidebar(formatTimer(secsLeft, true));
            secsLeft--;
        }
        tick();
        liveCountdownInterval = setInterval(tick, 1000);
    }

    // Show stored timer immediately if available
    (function showStoredTimer() {
        const info = getStoredWarInfo();
        let timerText = "0d 0h 0m";
        if (info.start && info.nextCheck && info.start > Math.floor(Date.now() / 1000)) {
            // Pending war
            const left = Math.max(0, info.start - Math.floor(Date.now() / 1000));
            timerText = formatTimer(left);
        } else if (info.start && info.nextCheck && info.start <= Math.floor(Date.now() / 1000)) {
            // Active war (just show zeros)
            timerText = "0d 0h 0m";
        }
        updateSidebar(timerText);
    })();

    // Add MutationObserver to detect when sidebar is added to DOM
    function setupMutationObserver() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    // Check if any added nodes contain sidebar elements
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the node itself is a sidebar
                            if (node.classList && (node.classList.contains('tt-sidebar-information') || node.classList.contains('sidebar-information'))) {
                                setTimeout(() => {
                                    initializeTimer(); // This will now move the timer if needed
                                }, 100);
                            }
                            // Check if the node contains a sidebar
                            const foundSidebar = node.querySelector && (node.querySelector('.tt-sidebar-information') || node.querySelector('.sidebar-information'));
                            if (foundSidebar) {
                                setTimeout(() => {
                                    initializeTimer(); // This will now move the timer if needed
                                }, 100);
                            }
                        }
                    });
                    
                    // Also check if sidebar exists now and timer doesn't exist or is in wrong place
                    const sidebar = findSidebar();
                    const existingTimer = document.getElementById(timerContainerID);
                    
                    if (sidebar && (!existingTimer || existingTimer.parentElement !== sidebar)) {
                        // Give it a moment to settle
                        setTimeout(() => {
                            initializeTimer(); // This will now move the timer if needed
                        }, 100);
                    }
                }
            });
        });

        // Start observing
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // Stop observing after 30 seconds to prevent memory leaks
        setTimeout(() => {
            observer.disconnect();
        }, 30000);
    }

    function initializeTimer() {
        const info = getStoredWarInfo();
        let timerText = "0d 0h 0m";
        if (info.start && info.nextCheck && info.start > Math.floor(Date.now() / 1000)) {
            // Pending war
            const left = Math.max(0, info.start - Math.floor(Date.now() / 1000));
            timerText = formatTimer(left);
        } else if (info.start && info.nextCheck && info.start <= Math.floor(Date.now() / 1000)) {
            // Active war (just show zeros)
            timerText = "0d 0h 0m";
        }
        
        // Check if timer already exists in wrong location
        const existingTimer = document.getElementById(timerContainerID);
        const currentSidebar = findSidebar();
        
        if (existingTimer && currentSidebar) {
            // If timer exists but is in wrong sidebar, move it
            const timerParent = existingTimer.parentElement;
            if (timerParent !== currentSidebar) {
                existingTimer.remove();
                // Force recreation by removing the element
            }
        }
        
        updateSidebar(timerText);
        runCheck();
    }

    // Try to initialize immediately
    waitForSidebar(initializeTimer);
    
    // Also setup mutation observer for dynamic content
    setupMutationObserver();
    
    // Fallback: Try again after a longer delay in case extensions are slow to load
    setTimeout(() => {
        if (!document.getElementById(timerContainerID)) {
            waitForSidebar(initializeTimer);
        }
    }, 5000);
})();