🇮🇷 Iran Proxy | https://www.wikipedia.org/wiki/User:Mathnerd314159/displayLibrary.js
Jump to content

User:Mathnerd314159/displayLibrary.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* Helper for the "10+ edits in past 30 days" criterion for the Wikipedia Library.
    Displays how long you can go without editing until access expires,
    and advice on when to edit. */
$(document).ready(function () {
    const thresholdN = 10; // Number of required edits
    const windowDays = 30; // Within this many days
    const hoardRateDays = 3; // Ideal rate: 1 edit every X days
    const expireWarningDays = 5.61; // Always remind within this many days of expiration
    const availableEditsTarget = 13 / hoardRateDays; // Target number of available edits to have buffered

    const millisecondsInDay = 24 * 60 * 60 * 1000;
    const fallOffAmount = windowDays * millisecondsInDay; // 30 days in milliseconds

    const INLINE_STYLES = {
        COLOR_GRAY: '#E5E7EB', // Pre-start
        COLOR_PALE_GREEN: '#A7F3D0', // Viable (State 1)
        COLOR_BRIGHT_GREEN: '#047857', // Optimal (State 2)
        COLOR_ORANGE: '#F97316', // Warning (State 3)
        COLOR_RED_BG: '#FECACA', // Expired Background
        COLOR_RED_TEXT: '#B91C1C', // Expired Text
        COLOR_GREEN_BG: '#D1FAE5', // Viable Status BG
        COLOR_ORANGE_BG: '#FFEAD0', // Warning Status BG
        COLOR_OPTIMAL_BG: '#A7F3D0', // Optimal Status BG
        COLOR_TEXT_PRIMARY: '#1F2937', // Dark gray text
    };

    const dateFormatter = new Intl.DateTimeFormat(undefined, {
        weekday: 'short',
        day: '2-digit',
        month: 'long',
        year: 'numeric'
    });
    const timeFormatter = new Intl.DateTimeFormat(undefined, {
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: true, // For AM/PM
        timeZoneName: 'short'
    });
    function formatTimeDifference(timeDifferenceMs) {
        console.assert(timeDifferenceMs >= 0, "timeDifferenceMs must be positive");

        let millisecondsInHour = 60 * 60 * 1000;
        let millisecondsInMinute = 1000 * 60;

        let value;
        let unit;

        if (timeDifferenceMs < millisecondsInHour) {
            // Less than 1 hour -> show in minutes
            value = timeDifferenceMs / millisecondsInMinute;
            unit = 'minute';
        } else if (timeDifferenceMs < millisecondsInDay) {
            // Less than 1 day -> show in hours
            value = timeDifferenceMs / millisecondsInHour;
            unit = 'hour';
        } else {
            // 1 day or more -> show in days
            value = timeDifferenceMs / millisecondsInDay;
            unit = 'day';
        }

        // Format the value to one decimal place
        const formattedValue = value.toFixed(1);

        // Determine if the unit should be plural
        const pluralUnit = (value <= 1) ? unit : unit + 's';

        return `${formattedValue} ${pluralUnit}`;
    }

    /**
     * Formats a timestamp into a div element with date and time parts.
     * @param {string} prefixText - Text to prefix the timestamp display.
     * @param {number} timestampMs - The timestamp in milliseconds.
     * @returns {HTMLDivElement} - A div element containing the formatted timestamp.
     */
    function formatTimestampToElements(prefixText, timestampMs, strong = false) {
        const date = new Date(timestampMs);
        const datePart = dateFormatter.format(date);
        const timePart = timeFormatter.format(date);
        const timestampContainerDiv = document.createElement('div');
        timestampContainerDiv.className = 'twl-timestamp-display'; // Add a class for potential styling
        let prefix = document.createTextNode(`${prefixText}:`);
        if (strong) {
            let prefixS = document.createElement('strong');
            prefixS.appendChild(prefix);
            timestampContainerDiv.appendChild(prefixS);
        } else {
            timestampContainerDiv.appendChild(prefix);
        }
        timestampContainerDiv.appendChild(document.createElement('br'));
        timestampContainerDiv.appendChild(document.createTextNode(datePart));
        timestampContainerDiv.appendChild(document.createElement('br'));
        timestampContainerDiv.appendChild(document.createTextNode(timePart));
        return timestampContainerDiv;
    }

    /**
     * Calculates the Earliest Start Time for making an edit to maintain the 1-edit/3-days cadence.
     * This is the earliest time that an edit will advance the user's access by 3 full days.
     * @param {number[]} sortedTimestamps - Array of edit timestamps, sorted newest to oldest.
     * @returns {number} The earliest optimal time to make an edit in milliseconds.
     */
    function calculateEarliestStartTime(sortedTimestamps) {
        console.assert(sortedTimestamps.length == thresholdN, "must have exactly thresholdN timestamps");
        let earliestStartTime = Infinity; // Initialize to infinity to find the min time

        // We only care about the 9 newest edits (indices 0 to 8) for maintaining the cadence.
        // The 10th edit (index 9) is only relevant for the hard expiration.
        for (let i = 0; i < thresholdN - 1; i++) { // Loop through up to the 9th newest edit
            const currentEditTimestamp = sortedTimestamps[i];
            // The deadline for this edit
            let potentialExpiry = currentEditTimestamp + fallOffAmount; // 30 days after this edit
            let numEditsToMake = thresholdN - (i + 1); // How many more edits are needed to reach 10
            let requiredHoardEditTime = potentialExpiry - (numEditsToMake * hoardRateDays * millisecondsInDay);

            earliestStartTime = Math.min(earliestStartTime, requiredHoardEditTime);
        }
        return earliestStartTime; // + 3 days for new edits shifting - 3 days of offset of deadline of current = just the earliest start time
    }

    function addPortlet(element) {
        /* todo: complicated skin logic */
        let portlets = ['p-personal', 'p-personal-sticky-header'];
        for (let portletId of portlets) {
            const portletListItem = mw.util.addPortletLink(portletId, "", '', 'pt-librarylimit', '', null, '#pt-logout');
            if (portletListItem) {
                portletListItem.firstChild.remove();
                const newElement = element.cloneNode(true);
                addEventListeners(newElement);
                portletListItem.append(newElement);
            }
        }
    }

    function addEventListeners(portletElement) {
        const dataHeader = portletElement.querySelector('.twl-data-header');
        const dataPointsDiv = portletElement.querySelector('.twl-data-points');
        const toggleIcon = dataHeader.querySelector('.twl-toggle-icon');
        // Toggle Functionality
        dataHeader.addEventListener('click', () => {
            const isHidden = dataPointsDiv.style.display === 'none';
            if (isHidden) {
                dataPointsDiv.style.display = 'flex';
                toggleIcon.textContent = '−'; // Minus for expanded state
            } else {
                dataPointsDiv.style.display = 'none';
                toggleIcon.textContent = '+'; // Plus for collapsed state
            }
        });
    }

    function plotDisplayContent(mainWrapper, now, times, editsAvailable) {
        let [earliestTimeMs, targetOptimalTimeMs, warningTimeMs, expirationTimeMs] = times;
        mainWrapper.style.padding = '10px';
        mainWrapper.style.backgroundColor = '#FFFFFF';
        mainWrapper.style.border = `2px solid ${INLINE_STYLES.COLOR_GRAY}`;

        const timelineContainer = document.createElement('div');
        timelineContainer.style.position = 'relative';
        timelineContainer.style.height = '1rem';
        mainWrapper.appendChild(timelineContainer);

        // --- 3. Define Segments ---
        const segments = [
            {
                duration: targetOptimalTimeMs - earliestTimeMs,
                color: INLINE_STYLES.COLOR_PALE_GREEN, // Pale Green (State 1: Viable)
                label: 'Viable'
            },
            {
                duration: warningTimeMs - targetOptimalTimeMs,
                color: INLINE_STYLES.COLOR_BRIGHT_GREEN, // Bright Green (State 2: Optimal)
                label: 'Optimal'
            },
            {
                duration: expirationTimeMs - warningTimeMs,
                color: INLINE_STYLES.COLOR_ORANGE, // Orange (State 3: Warning/High Miss Chance)
                label: 'Warning'
            }
        ];

        if (now < earliestTimeMs) {
            // Pre-start segment (Gray)
            segments.unshift({
                duration: earliestTimeMs - now,
                color: INLINE_STYLES.COLOR_GRAY,
                label: 'Too early'
            });
        }

        if (now > expirationTimeMs) {
            // Post-expiration segment (Red)
            segments.push({
                duration: now - expirationTimeMs,
                color: INLINE_STYLES.COLOR_RED_BG,
                label: 'Expired'
            });
        }

        const totalDuration = segments.reduce((sum, seg) => sum + seg.duration, 0);
        const startTime = now < earliestTimeMs ? now : earliestTimeMs;

        // Render Segments
        segments.forEach(seg => {
            const widthPercent = (seg.duration / totalDuration) * 100;
            const segmentDiv = document.createElement('div');
            segmentDiv.style.height = '100%';
            segmentDiv.style.float = 'left';
            segmentDiv.style.backgroundColor = seg.color;
            segmentDiv.style.width = `${widthPercent.toFixed(2)}%`;
            segmentDiv.title = `${seg.label}: ${formatTimeDifference(seg.duration)}`;
            timelineContainer.appendChild(segmentDiv);
        });

        // --- 4. Calculate And Render Current Time Marker Position ---
        let currentPositionPercent = (now - startTime) / totalDuration * 100;
        const markerDiv = document.createElement('div');
        markerDiv.style.position = 'absolute';
        markerDiv.style.top = '0';
        markerDiv.style.width = '3px';
        markerDiv.style.height = '100%';
        markerDiv.style.backgroundColor = '#000000'; // Black marker
        markerDiv.style.borderRadius = '1.5px';
        markerDiv.style.zIndex = '10';
        markerDiv.style.transition = 'left 0.5s ease-out';
        markerDiv.style.left = `${currentPositionPercent.toFixed(2)}%`;
        markerDiv.style.transform = 'translateX(-50%)'; 
        timelineContainer.appendChild(markerDiv);

        // --- 5. Add Daily Tick Marks (Overlaying the bar, half height, no labels) ---
        
        // Calculate the start of the first full day after earliestTimeMs
        let d = new Date(startTime);
        d.setHours(0, 0, 0, 0);
        // Move to the beginning of the next day (to get the first full day boundary)
        let tickMs = d.getTime() + millisecondsInDay; 
        
        // Loop through daily ticks until expiration
        while (tickMs < expirationTimeMs) {
            const currentPositionPercent = (tickMs - startTime) / totalDuration * 100;

            // Create tick mark line
            const tickMark = document.createElement('div');
            tickMark.style.position = 'absolute';
            tickMark.style.left = `${currentPositionPercent.toFixed(2)}%`;
            tickMark.style.top = '0%'; // Start at the center line
            tickMark.style.width = '1px';
            tickMark.style.height = '50%'; // Extend halfway vertically (2rem)
            tickMark.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'; // Subtle dark tick
            tickMark.style.zIndex = '5'; // Below the current time marker (z-index 10)
            // Center horizontally (X) by half its height
            tickMark.style.transform = 'translateX(-50%)'; 
            
            timelineContainer.appendChild(tickMark);

            tickMs += millisecondsInDay;
        }

        // --- 6. Render Status ---
        let statusText;
        let statusColor;
        let statusBgColor;

        if (now < earliestTimeMs) {
            // Before Earliest Time (Gray status)
            statusText = `Wait (${formatTimeDifference(earliestTimeMs - now)})`;
            statusColor = INLINE_STYLES.COLOR_TEXT_PRIMARY;
            statusBgColor = INLINE_STYLES.COLOR_GRAY;
        } else if (now > expirationTimeMs) {
            // After Expiration (Red status)
            statusText = `Expired (${formatTimeDifference(now - expirationTimeMs)})`;
            statusColor = INLINE_STYLES.COLOR_RED_TEXT;
            statusBgColor = INLINE_STYLES.COLOR_RED_BG;
        } else if (now < targetOptimalTimeMs) {
            statusText = `Edit shrewdly (${formatTimeDifference(targetOptimalTimeMs - now)})`;
            statusColor = INLINE_STYLES.COLOR_BRIGHT_GREEN;
            statusBgColor = INLINE_STYLES.COLOR_GREEN_BG;
        } else if (now < warningTimeMs) {
            statusText = `Edit now! (${formatTimeDifference(warningTimeMs - now)})`;
            statusColor = '#FFFFFF'; // White text for dark green background
            statusBgColor = INLINE_STYLES.COLOR_BRIGHT_GREEN;
        } else { // now < expirationTimeMs
            statusText = `Expiration imminent! (${formatTimeDifference(expirationTimeMs - now)})`;
            statusColor = INLINE_STYLES.COLOR_ORANGE;
            statusBgColor = INLINE_STYLES.COLOR_ORANGE_BG;
        }

        const statusMessage = document.createElement('div');
        statusMessage.textContent = statusText;
        statusMessage.style.fontWeight = '600';
        statusMessage.style.textAlign = 'center';
        statusMessage.style.backgroundColor = statusBgColor;
        statusMessage.style.color = statusColor;
        mainWrapper.appendChild(statusMessage);

        const editsDiv = document.createElement('div');
        editsDiv.textContent = `${editsAvailable} edits available`;
        editsDiv.style.fontWeight = '600';
        editsDiv.style.textAlign = 'center';
        mainWrapper.appendChild(editsDiv);

        // --- 7. Render Data Points (for Debug/Context) ---
        const dataHeader = document.createElement('div');
        dataHeader.textContent = 'Timestamp Data';
        dataHeader.className = 'twl-data-header';
        dataHeader.style.fontWeight = '500';
        dataHeader.style.color = INLINE_STYLES.COLOR_TEXT_PRIMARY;
        dataHeader.style.paddingTop = '0px';
        dataHeader.style.cursor = 'pointer';
        dataHeader.style.userSelect = 'none';
        dataHeader.style.display = 'flex';
        dataHeader.style.justifyContent = 'space-between';
        dataHeader.style.alignItems = 'center';

        const toggleIcon = document.createElement('span');
        toggleIcon.className = 'twl-toggle-icon';
        toggleIcon.textContent = '+'; // Plus for collapsed state
        toggleIcon.style.marginLeft = '10px';
        toggleIcon.style.transition = 'transform 0.2s';
        dataHeader.appendChild(toggleIcon);

        const dataPointsDiv = document.createElement('div');
        dataPointsDiv.className = 'twl-data-points';
        dataPointsDiv.style.display = 'none'; // Initially collapsed
        // Simple flex layout for better responsiveness without complex grid
        dataPointsDiv.style.flexWrap = 'wrap';
        dataPointsDiv.style.gap = '10px';
        dataPointsDiv.style.fontSize = '0.875rem';
        dataPointsDiv.style.color = INLINE_STYLES.COLOR_TEXT_PRIMARY;

        const data = {
            'Current Time': now,
            'Earliest Viable': earliestTimeMs,
            'Target Optimal': targetOptimalTimeMs,
            'Warning Time': warningTimeMs,
            'Expiration Time': expirationTimeMs
        };

        for (const [label, time] of Object.entries(data)) {
            const item = formatTimestampToElements(label, time);
            dataPointsDiv.appendChild(item);
        }

        mainWrapper.appendChild(dataHeader);
        mainWrapper.appendChild(dataPointsDiv);
    }

    // Ensure that mediawiki.user and mediawiki.api modules are loaded before proceeding.
    mw.loader.using(['mediawiki.user', 'mediawiki.api'], function() {
        // Get the current username from MediaWiki configuration.
        const username = mw.config.get('wgUserName');

        // Check if a username is available. If not, there's no user to query.
        if (!username) {
            console.log('No user logged in or username not available.');
            return;
        }

        // Initialize a new MediaWiki API object.
        (new mw.Api()).get({
            action: 'query',
            list: 'usercontribs',
            ucuser: username,
            uclimit: thresholdN, // Request exactly thresholdN (10) contributions
            ucprop: 'timestamp'
        }).done(function(result) {
            if (result.query && result.query.usercontribs && result.query.usercontribs.length >= thresholdN) {
                const userContribs = result.query.usercontribs;
                let timestamps = userContribs.map(contrib => new Date(contrib.timestamp).getTime());
                window.timestamps = timestamps; // Store timestamps globally for debugging

                const now = new Date().getTime();// Current time in milliseconds

                // --- Core Logic for Edit Window ---

                // Calculate Expiration Time (ET) - this is the hard deadline
                const expirationTime = timestamps[thresholdN - 1] + fallOffAmount; // 10th newest edit + 30 days
                const expirationWarningTime = expirationTime - (expireWarningDays * millisecondsInDay);

                // Calculate Earliest Start Time - based on maintaining cadence
                const optimalStartTime = calculateEarliestStartTime(timestamps);
                let targetStartTime = Math.min(optimalStartTime + availableEditsTarget * hoardRateDays * millisecondsInDay, expirationWarningTime);
                let editsAvailable = Math.max(0, Math.min(thresholdN, Math.ceil((now - optimalStartTime) / (hoardRateDays * millisecondsInDay))));
                
                // --- Display Logic ---
                let displayContent = document.createElement('div');
                displayContent.className = 'twl-widget-content';

                plotDisplayContent(displayContent, now, [optimalStartTime, targetStartTime, expirationWarningTime, expirationTime], editsAvailable);
                addPortlet(displayContent);
            } else {
                console.log('Error: No user contributions found or query failed.');
                displayContent = document.createElement('div');
                displayContent.innerHTML = "<p>Could not retrieve edit history. Please check the console.</p>";
                addPortlet(displayContent);
            }
        }).fail(function(jqXHR, textStatus, errorThrown) {
            console.error('MediaWiki API request failed:', textStatus, errorThrown);
            const displayContent = document.createElement('div');
            displayContent.innerHTML = `<p>Error fetching edit history: ${textStatus}.</p>`;
            addPortlet(displayContent);
        });
    });
});