User:Mathnerd314159/displayLibrary.js

/* 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. */

// ============================================================================
// Basic Constants and Configuration
// ============================================================================
(function() {
const thresholdN = 10; // Number of required edits
const windowDays = 30; // Within this many days
const millisecondsInDay = 24 * 60 * 60 * 1000;
const fallOffAmount = windowDays * millisecondsInDay; // 30 days in milliseconds

// ============================================================================
// Editing Model Parameters (Fitted from historical data)
// Note: These were fitted in seconds, but we use milliseconds as the time unit, see conversions
// ============================================================================
const MODEL_PARAMS = {
  "within_session": {
    "dist": "weibull_min",
    "params": {
      "shape": 0.6297097219274612,
      "loc": 59.999999999999986 * 1000, // Convert from seconds to milliseconds for sampling
      "scale": 712.3786226674033 * 1000 // Convert from seconds to milliseconds for sampling
    }
  },
  "between_session": {
    "dist": "lognorm",
    "params": {
      "shape": 1.9807654645077275,
      "loc": 3571.389575548099 * 1000, // Convert from seconds to milliseconds for sampling
      "scale": 35022.39826439742 * 1000 // Convert from seconds to milliseconds for sampling
    }
  },
  "session_size_dist": [
    0.0,
    0.5807807807807808,
    0.7957957957957958,
    0.8936936936936937,
    0.9435435435435435,
    0.9657657657657658,
    0.9771771771771772,
    0.9831831831831832,
    0.9885885885885886,
    0.9915915915915916,
    1.0
  ]
};

// ============================================================================
// Statistical Distribution Functions - Generate Future Edit Patterns
// ============================================================================

/**
 * Box-Muller transform: generate standard normal random variable
 */
function sampleStdNormal() {
  let r = Math.sqrt(-2 * Math.log(Math.random()));
  let theta = 2 * Math.PI * Math.random();
  return r * Math.cos(theta);
  // Note: could also return r * Math.sin(theta) for a second independent sample
}

/**
 * Standard Normal Cumulative Distribution Function (Φ)
 * Returns the probability that a standard normal variable is <= z
 */
function stdNormalCDF(z) {
    const t = 1 / (1 + 0.2315419 * Math.abs(z));
    const d = 0.3989423 * Math.exp(-z * z / 2);
    let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
    return (z > 0) ? 1 - prob : prob;
}

/**
 * Approximate the Standard Normal Inverse CDF (Probit function)
 * Accuracy: ~10^-9
 * @param {number} p - Probability (0 < p < 1)
 */
function stdNormalInverseCDF(p) {
  if (p <= 0 || p >= 1) return NaN;

  // Coefficients for the rational approximation
  const a = [-3.969683028665376e+01,  2.209460984245205e+02,
             -2.759285104469687e+02,  1.383577518672690e+02,
             -3.066479806614716e+01,  2.506628277459239e+00];
  const b = [-5.447609879822406e+01,  1.615858368580409e+02,
             -1.556989798598866e+02,  6.680131188771972e+01,
             -1.328068155288572e+01];
  const c = [-7.784894002430293e-03, -3.223964580411365e-01,
             -2.400758277161838e+00, -2.549732569343734e+00,
              4.374664141464968e+00,  2.938163982698783e+00];
  const d = [ 7.784695709041462e-03,  3.224671290700398e-01,
              2.445134137142996e+00,  3.754408661907416e+00];

  const p_low = 0.02425;
  const p_high = 1 - p_low;
  let q, r;

  // Rational approximation for lower region
  if (p < p_low) {
    q = Math.sqrt(-2 * Math.log(p));
    return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
           ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
  }

  // Rational approximation for upper region
  if (p > p_high) {
    q = Math.sqrt(-2 * Math.log(1 - p));
    return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
            ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
  }

  // Rational approximation for central region
  q = p - 0.5;
  r = q * q;
  return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
         (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
}

/**
 * Sample from Weibull distribution with given shape, loc, scale
 * Weibull CDF: F(x) = 1 - exp(-((x - loc) / scale)^shape)
 * Inverse: x = loc + scale * (-ln(1 - u))^(1/shape)
 */
function sampleWeibull(shape, loc, scale) {
  let u = Math.random();
  return loc + scale * Math.pow(-Math.log(1 - u), 1 / shape);
}

/**
 * Sample from lognormal distribution
 * lognormal(shape, loc, scale): exp(loc + scale * Z) where Z ~ N(0, 1)
 * Here shape is the std dev of the underlying normal
 */
function sampleLognormal(shape, loc, scale) {
  let z = sampleStdNormal();
  return loc + scale * Math.exp(shape * z);
}

function sampleConditionedLognormal(shape, loc, scale, cutoff) {
  // If the cutoff is less than or equal to the location parameter, 
  // no truncation is needed (because scale * exp(...) is always positive)
  if (cutoff <= loc) {
    return sampleLognormal(shape, loc, scale);
  }

  // 1. Calculate the minimum z required to satisfy the cutoff
  let z_min = Math.log((cutoff - loc) / scale) / shape;
  
  // 2. Find the cumulative probability of z_min
  let p_min = stdNormalCDF(z_min); // e.g., jStat.normal.cdf(z_min, 0, 1)
  
  // 3. Sample a uniform random variable and map it to the valid range [p_min, 1]
  let u = Math.random();
  let p = p_min + u * (1 - p_min);
  
  // 4. Invert the probability back to a standard normal z-score
  let z = stdNormalInverseCDF(p);      // e.g., jStat.normal.inv(p, 0, 1)
  
  // 5. Convert to the final lognormal sample
  return loc + scale * Math.exp(shape * z);
}

/**
 * Sample from discrete distribution (session size)
 * @param {Array<number>} cumulative_probabilities - Array of summed probabilities (last element should be 1)
 * @returns {number} - Index sampled according to the probability distribution
 */
function sampleSessionSize(cumulative_probabilities) {
  let u = Math.random();
  for (let i = 0; i < cumulative_probabilities.length; i++) {
    if (u <= cumulative_probabilities[i]) {
      return i;
    }
  }
  
  // Fallback (shouldn't reach here if last probability is 1)
  return cumulative_probabilities.length - 1;
}

/**
 * Generate future editing timeline using session-aware model, conditioned on a new edit session starting at a specific time
 * @param {number} first_edit - Timestamp of the first edit (milliseconds, relative to most recent edit at t=0)
 * @returns {Array<number>} - Array of relative times (after most recent edit at t=0)
 */
function generateFutureTimestampsConditional(first_edit) {
  let future_times = [];
  let cur_time = 0;
  
  while (future_times.length < thresholdN) {
    // Wait for next session
    let session_wait = future_times.length == 0 ? first_edit : sampleLognormal(MODEL_PARAMS.between_session.params.shape, MODEL_PARAMS.between_session.params.loc, MODEL_PARAMS.between_session.params.scale);
    cur_time += session_wait; // Already in milliseconds
    future_times.push(cur_time);
    
    // Sample session size
    let num_edits_in_session = sampleSessionSize(MODEL_PARAMS.session_size_dist);
    
    // Generate within-session edits
    let num_edits = 1;  // Already have first edit
    while (num_edits < num_edits_in_session && future_times.length < thresholdN) {
      let within_time = sampleWeibull(MODEL_PARAMS.within_session.params.shape, MODEL_PARAMS.within_session.params.loc, MODEL_PARAMS.within_session.params.scale);
      cur_time += within_time; // Already in milliseconds
      future_times.push(cur_time);
      num_edits += 1;
    }
  }
  
  // Trim to exactly thresholdN edits
  future_times = future_times.slice(0, thresholdN);
  return future_times;
}

function runSimulation_equal_failurerate(past_edits, numSimulations, delay) {
    let failedSimulations = 0;
    for (let i = 0; i < numSimulations; i++) {
        let future = generateFutureTimestampsConditional(delay);
        if (doesLoseAccess(past_edits, future)) {
            failedSimulations += 1;
        }
    }
    return failedSimulations / numSimulations;
}

function runSimulation_greater_failurerate_nextsessionok(past_edits, numSimulations, delay) {
    let failedSimulations = 0;
    let successfulSimulations = 0;
    let next_session_starts_ok = [];
    for (let i = 0; (successfulSimulations === 0 ? i < numSimulations : successfulSimulations < numSimulations); i++) {
        let startDelay = sampleConditionedLognormal(
            MODEL_PARAMS.between_session.params.shape, 
            MODEL_PARAMS.between_session.params.loc, 
            MODEL_PARAMS.between_session.params.scale,
            delay
        );
        let future = generateFutureTimestampsConditional(startDelay);
        if (doesLoseAccess(past_edits, future)) {
            failedSimulations += 1;
        } else {
            successfulSimulations += 1;
            next_session_starts_ok.push(future[0]);
        }
    }
    next_session_starts_ok.sort((a, b) => a - b);
    return { failure_rate: failedSimulations / (failedSimulations + successfulSimulations), next_session_starts_ok };
}

// ============================================================================
// Metric calculations
// ============================================================================

/**
 * Convert raw millisecond timestamps to a past edit pattern
 * 
 * Input: Array of 10 most recent edit timestamps (in milliseconds)
 *        Ordered newest-first (0 is newest, 9 is oldest)
 * Output: Array of times since most recent edit (in milliseconds)
 *         Most recent edit will be 0 and at end of array
 */
function timestampsToPattern(timestamps_ms) {
  let mostRecent = timestamps_ms[0];
  let pattern = timestamps_ms.map(ts => ts - mostRecent).reverse();
  return pattern;
}

// ============================================================================
// New Proposed Metrics - Advanced Monte Carlo Evaluation
// ============================================================================

/**
 * Helper: Deterministically check if a specific future pattern results in losing access.
 * Checks exactly at the moments when past edits expire.
 */
function doesLoseAccess(past_edits, future_edits) {
    console.assert(past_edits.length === thresholdN, "Expected exactly thresholdN past edits");
    console.assert(future_edits.length === thresholdN, "Expected exactly thresholdN future edits");
    let all_edits = past_edits.concat(future_edits).sort((a, b) => a - b); // Sort all edits chronologically
    for (let i = 0; i < all_edits.length - thresholdN; i++) {
        // The exact moment this past edit reaches 30 days old
        let expiration_time = all_edits[i] + fallOffAmount; 
        if (all_edits[i + thresholdN] >= expiration_time) {
            return true; // Access expires before the next future edit
        }
    }
    return false;
}

/**
 * Calculates the exact hyperbola parameter 'b' directly from raw simulation samples
 * using Algebraic Least Squares, avoiding all percentile approximation errors.
 * 
 * The formula is derived from the rearrangement of the hyperbola equation:
 * S = (y - d)/ (t_exp - d) = (b-1) * (p/(b-p))
 * b(p-S) = p(1-S)
 * 
 * The coefficient of b can be easily computed using standard linear least squares on the transformed variables.
 * 
 * @param {Array<number>} raw_start_times_ms - Sorted array of successful start times in ms
 * @param {number} d - The forced delay in milliseconds
 * @param {number} t_exp - Timestamp of expiration in milliseconds
 * @param {number} t_mre - Timestamp of the most recent edit in ms
 * @returns {number} The exact fitted parameter 'b'
 */
function calculateExactB(raw_start_times_ms, d, t_exp, t_mre) {
    let fallback_b = 1.00001; // Minimum value for b to ensure the curve is valid
    // Mathematically, b must be > 1 to serve as the asymptote outside the [0, 1] domain

    const N = raw_start_times_ms.length;
    if (N === 0) return fallback_b; // Fallback

    let sumXY = 0;
    let sumXX = 0;

    for (let i = 0; i < N; i++) {
        // Empirical CDF (percentile) for this sample
        let p = (i + 0.5) / N; 
        
        // Convert to relative to most recent edit
        let y_days = raw_start_times_ms[i];
        
        // Calculate normalized slack (clamp between 0 and 1 for safety)
        let S = (y_days - d) / (t_exp - t_mre - d);
        S = Math.max(0, Math.min(1, S)); 

        // Linearized variables
        let X = p - S;
        let Y = p * (1 - S);

        sumXY += X * Y;
        sumXX += X * X;
    }

    // Solve the algebraic least squares
    let b = sumXX > 0 ? (sumXY / sumXX) : fallback_b;
    return Math.max(fallback_b, b); 
}
// ============================================================================
// Formatting and Display
// ============================================================================

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;
}

function addPortlet(element) {
    /* skin logic - add more id's as needed */
    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 clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
}

/**
 * Main routine to calculate timeline thresholds for the Wikipedia Library Widget.
 * @param {Array<number>} past_edits - Array of edit timestamps (ms), sorted newest first.
 */
function calculateStuff(past_edits, numSimulations, timeline) {
    let t_exp = timeline.expirationTime - timeline.mostRecentEdit;

    // 1. Define the 3 sample points for our algebraic fits (n.b. these are relative to the most recent edit at t=0)
    const d1 = 0; // immediately after most recent edit
    const d2 = t_exp / 2;
    const d3 = Math.max(t_exp - 3 * millisecondsInDay, d2 + 0.1 * millisecondsInDay); // Ensure d3 is distinctly greater than d2
    const x_vals = [d1, d2, d3];

    // 3. Run Monte Carlo simulations at the 3 points
    let simEq = [];
    for (let delay of x_vals) {
        simEq.push(runSimulation_equal_failurerate(past_edits, numSimulations, delay));
    }

    let simGr_failurerate = [];
    let b_vals = [];
    for (let i = 0; i < x_vals.length; i++) {
        let sim = runSimulation_greater_failurerate_nextsessionok(past_edits, numSimulations, x_vals[i]);
        simGr_failurerate.push(sim.failure_rate);
        // Part 1 for Step C - Calculate Exact 'b' for each simulation directly from the percentiles
        let b_val = calculateExactB(sim.next_session_starts_ok, x_vals[i], timeline.expirationTime, timeline.mostRecentEdit);
        b_vals.push(b_val);
    }

    // ==========================================
    // STEP A: Fit Rational Curve to delay_equal
    // Equation: y = a / (b - x) + c
    // ==========================================
    const [y1, y2, y3] = simEq;
    let a_rat = 0, b_rat = 100, c_rat = y1; // Safe defaults for perfectly flat risk

    // Prevent divide-by-zero if curve is flat or degenerate
    if (Math.abs(y1 - y2) > 1e-6 && Math.abs(y2 - y3) > 1e-6) {
        const R = ((y1 - y2) / (y2 - y3)) * ((d2 - d3) / (d1 - d2));
        if (Math.abs(R - 1) > 1e-6) {
            b_rat = (R * d1 - d3) / (R - 1);
            a_rat = (y1 - y2) * (b_rat - d1) * (b_rat - d2) / (d1 - d2);
            c_rat = y1 - a_rat / (b_rat - d1);
        }
    }

    // ==========================================
    // STEP B: Fit Linear Curve to delay_greater
    // Equation: y = a_lin * x + b_lin
    // ==========================================
    const y_vals = simGr_failurerate;
    let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
    for (let i = 0; i < 3; i++) {
        sumX += x_vals[i]; sumY += y_vals[i];
        sumXY += x_vals[i] * y_vals[i]; sumXX += x_vals[i] * x_vals[i];
    }
    const a_lin = (3 * sumXY - sumX * sumY) / (3 * sumXX - sumX * sumX);
    const b_lin = (sumY - a_lin * sumX) / 3;

    // ==========================================
    // STEP C: Fit next_session_start_stats_ok curves to delay_greater
    // Equation 1: y(p,d)=d+(t_exp−d)⋅(b(d)−1)⋅(p/(b(d)−p))
    // Equation 2: b(d) = a/(c-d)-f
    // ==========================================

    const [X1, X2, X3] = x_vals;
    const [Y1, Y2, Y3] = b_vals;

    // 2. Fit 3-point rational curve b(d) = a / (c - d) - f 
    // Mapped as Y = A / (C - X) + F_fit  (so f = -F_fit)
    let a = 15.46, c = 23.46, f = -0.43; // Saftey fallbacks based on global data
    if (Math.abs(Y1 - Y2) > 1e-6 && Math.abs(Y2 - Y3) > 1e-6) {
        let R = ((Y1 - Y2) / (Y2 - Y3)) * ((X2 - X3) / (X1 - X2));
        if (Math.abs(R - 1) > 1e-6) {
            c = (R * X1 - X3) / (R - 1);
            a = (Y1 - Y2) * (c - X1) * (c - X2) / (X1 - X2);
            let F_fit = Y1 - (a / (c - X1));
            f = -F_fit; 
        }
    }

    // 3. Find maximum of slack time $S(d) = y(p,d) - d$.
    // Calculus: Solve S'(d)=0
    // Collapses into quadratic formula: z^2 * Aq + z * Bq + Cq = 0 where z = c - d
    let p = 0.5; // We care most about the median point for the optimal window
    let Aq = (f + 1) * (f + p);
    let Bq = -2 * a * (f + 1);
    let Cq = (a * a) - (a * (1 - p) * (t_exp - c));

    let discriminant = (Bq * Bq) - (4 * Aq * Cq);
    let optimal_d = 0; // Default to 'now' if unsolvable
    let max_slack = -1;

    if (discriminant >= 0 && Math.abs(Aq) > 1e-6) {
        let z1 = (-Bq + Math.sqrt(discriminant)) / (2 * Aq);
        let z2 = (-Bq - Math.sqrt(discriminant)) / (2 * Aq);
        
        let d1 = c - z1;
        let d2 = c - z2;

        // Evaluate which valid root yields the higher slack
        [d1, d2].forEach(d => {
            if (d >= 0 && d <= t_exp) {
                let b_test = (a / (c - d)) - f;
                if (b_test > p) {
                    let slack = (t_exp - d) * (b_test - 1) * (p / (b_test - p));
                    if (slack > max_slack) {
                        max_slack = slack;
                        optimal_d = d;
                    }
                }
            }
        });
    }

    // ==========================================
    // STEP C: Compute Thresholds
    // ==========================================

    // 1. Pale Green (Negligible Edit Impact)
    // Find x where y(x) = y(0) / 0.90
    let pale_green = 0; // Default to immediate if curve is degenerate or already very low risk
    if (y1 > 0 && y1 < 0.90 && a_rat !== 0) {
        let y_target = y1 / 0.90;
        let denominator = y_target - c_rat;
        if (denominator !== 0) {
            pale_green = b_rat - (a_rat / denominator);
        }
    }

    // 2. Dark Green (Optimal Median Window)
    // This is simply the point of maximum slack calculated above, which directly corresponds to the optimal point on the rational curve fit to the "next session starts ok" data.
    let dark_green = optimal_d;

    // 3. Orange Window (75% Baseline Risk)
    // Find x where linear fit y = 0.75
    let orange = t_exp; // Default to expiration if curve is degenerate or already very high risk
    if (simGr_failurerate[0] >= 0.75) {
        orange = 0; // Already in high risk
    } else if (a_lin > 0) {
        orange = (0.75 - b_lin) / a_lin;
    }

    // 4. Red Window (Inflection Point / Panic Zone)
    // Find x where derivative of rational curve matches average failure rate slope
    let red = t_exp; // Default to expiration if unsolvable
    let m = (1.0 - y1) / t_exp; // Average slope to expiration
    if (m > 0 && a_rat > 0) {
        let inside_sqrt = a_rat / m;
        if (inside_sqrt > 0) {
            red = b_rat - Math.sqrt(inside_sqrt);
        }
    }

    // ==========================================
    // STEP D: Enforce Monotonic Sequence & Bounds
    // ==========================================
    pale_green = clamp(pale_green + timeline.mostRecentEdit, timeline.mostRecentEdit, timeline.expirationTime);
    dark_green = clamp(dark_green + timeline.mostRecentEdit, pale_green, timeline.expirationTime);
    orange     = clamp(orange + timeline.mostRecentEdit, dark_green, timeline.expirationTime);
    red        = clamp(red + timeline.mostRecentEdit, orange, timeline.expirationTime);

    return {
        pale_green_start: pale_green,
        dark_green_start: dark_green,
        orange_start: orange,
        red_start: red
    };
}

function plotDisplayContent(mainWrapper, timeline, metrics) {
    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 totalDuration = timeline.expirationTime - timeline.mostRecentEdit;

    const segments = [
        {
            duration: metrics.pale_green_start - timeline.mostRecentEdit,
            color: INLINE_STYLES.COLOR_GRAY,
            label: 'Too early'
        },
        {
            duration: metrics.dark_green_start - metrics.pale_green_start,
            color: INLINE_STYLES.COLOR_PALE_GREEN,
            label: 'Viable'
        },
        {
            duration: metrics.orange_start - metrics.dark_green_start,
            color: INLINE_STYLES.COLOR_BRIGHT_GREEN,
            label: 'Optimal'
        },
        {
            duration: metrics.red_start - metrics.orange_start,
            color: INLINE_STYLES.COLOR_ORANGE,
            label: 'Warning'
        },
        {
            duration: timeline.expirationTime - metrics.red_start,
            color: INLINE_STYLES.COLOR_RED_TEXT,
            label: 'Expiration imminent'
        }
    ];

    // 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 = (timeline.now - timeline.mostRecentEdit) / 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 containing window start
    let d = new Date(timeline.mostRecentEdit);
    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 timeline end
    while (tickMs < timeline.expirationTime) {
        const currentPositionPercent = (tickMs - timeline.mostRecentEdit) / 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 (timeline.now < metrics.pale_green_start) {
        // Before Earliest Time (Gray status)
        statusText = `Go away for ${formatTimeDifference(metrics.pale_green_start - timeline.now)}`;
        statusColor = INLINE_STYLES.COLOR_TEXT_PRIMARY;
        statusBgColor = INLINE_STYLES.COLOR_GRAY;
    } else if (timeline.now < metrics.dark_green_start) {
        // Before Dark Green Start (Pale green status)
        statusText = `Wait (${formatTimeDifference(metrics.dark_green_start - timeline.now)} - ${formatTimeDifference(metrics.orange_start - timeline.now)})`;
        statusColor = INLINE_STYLES.COLOR_TEXT_PRIMARY;
        statusBgColor = INLINE_STYLES.COLOR_PALE_GREEN;
    } else if (timeline.now > timeline.expirationTime) {
        // After Expiration (Red status)
        statusText = `Expired (${formatTimeDifference(timeline.now - timeline.expirationTime)})`;
        statusColor = INLINE_STYLES.COLOR_RED_TEXT;
        statusBgColor = INLINE_STYLES.COLOR_RED_BG;
    } else if (timeline.now < metrics.orange_start) {
        // Bright green status
        statusText = `Edit shrewdly (${formatTimeDifference(metrics.orange_start - timeline.now)})`;
        statusColor = INLINE_STYLES.COLOR_BRIGHT_GREEN;
        statusBgColor = INLINE_STYLES.COLOR_GREEN_BG;
    } else { // data.orange_start < window.now < data.expiration
        statusText = `Edit now! (${formatTimeDifference(timeline.expirationTime - timeline.now)} to expiration)`;
        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);

    // --- 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 = {
        'Window Start': timeline.mostRecentEdit,
        'Current Time': timeline.now,
        'Pale Green Start': metrics.pale_green_start,
        'Dark Green Start': metrics.dark_green_start,
        'Orange Start': metrics.orange_start,
        'Red Start': metrics.red_start,
        'Expiration Time': timeline.expirationTime,
        'Window End': timeline.end,
    };
    console.log("Timeline and Metrics Data: ", { timeline, metrics, data }); // Log raw data for debugging

    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() {
    $(document).ready(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());
                // timestamps are returned in newest-first order
                window.timestamps = timestamps; // Store timestamps globally for debugging
                const now = new Date().getTime();// Current time in milliseconds

                // --- Core Logic for Edit Window ---
                // Calculate editing window and various time points
                // expiration time - this is the hard deadline
                const t_10th = timestamps[thresholdN - 1]; // 10th most recent edit
                const expirationTime = t_10th + fallOffAmount; // 10th newest edit + 30 days
                const mostRecentEdit = timestamps[0]; // window start
                const windowEnd = mostRecentEdit + fallOffAmount; // 30 days after most recent edit
                let timeline = {
                    t_10th: t_10th,
                    mostRecentEdit: mostRecentEdit,
                    now: now,
                    expirationTime: expirationTime,
                    end: windowEnd,
                };

                // Compute metrics - stateless
                const past_pattern = timestampsToPattern(timestamps);
                let numSimulations = 4000; // Number of Monte Carlo simulations for each metric - adjust as needed for performance/accuracy tradeoff
                const metrics = calculateStuff(past_pattern, numSimulations, timeline);
                window.metrics = {"metrics": metrics, "timeline": timeline}; // Store metrics globally for debugging
                // console.log(JSON.stringify(metrics, null, 2)); // Log metrics for debugging

                // --- Display Logic ---
                let displayContent = document.createElement('div');
                displayContent.className = 'twl-widget-content';
                plotDisplayContent(displayContent, timeline, metrics);
                addPortlet(displayContent);
            } else {
                console.log('Error: No user contributions found or query failed.');
                let 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);
        });
    });
});
})()

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.