User:Mathnerd314159/displayLibrary.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Mathnerd314159/displayLibrary.
/* 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);
});
});
});