WKStats Levelup Fix

Fix weird issues with the levelups on wkstats that are assumed to be caused by new additions of kanji to levels

// ==UserScript==
// @name         WKStats Levelup Fix
// @namespace    https://greasyfork.org/en/users/11878
// @version      2.0.2
// @description  Fix weird issues with the levelups on wkstats that are assumed to be caused by new additions of kanji to levels
// @author       Inserio
// @match        https://www.wkstats.com/progress/dashboard
// @match        https://www.wkstats.com/progress/level-up
// @match        https://www.wkstats.com/progress/projections
// @match        https://www.wkstats.com/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wkstats.com
// @license      MIT; http://opensource.org/licenses/MIT
// @run-at       document-start
// @grant        none
// ==/UserScript==
/*global wkof, wkdata, wkstats, calc_levelups, log, yyyymmdd, duration, wklogs*/
(function() {
    'use strict';
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }

    function init() {
        const calc_stats = wkof.support_files['calc_stats.js'];
        if (calc_stats === undefined || calc_stats === null || !calc_stats.includes('1.0.7')) // only run on this version
            return;
        wkof.wait_state(/* state_var */ "wkof.wkstats.levelups",/* value */ "ready",/* callback */ overwriteFunction,/* persistent */ false);
    }

    function overwriteFunction() {
        console.log('Overriding the calc_levelups() function definition');
        // eslint-disable-next-line no-global-assign
        calc_levelups = function() {
            wklogs['levelups'].length = 0; // clear existing logs (comment out if you want to compare results with the default version, which gets run first)
            log('levelups', 'Overriding previous data of calc_levelups() with modified version');
            let level_times = wkstats.level_times = [];

            // For each level, initialize a valid range of possible level times (initial = any time)
            for (let level = 1; level <= wkof.user.subscription.max_level_granted; level++) {
                level_times[level] = {
                    min: new Date(0),
                    max: wkdata.load_time,
                    dates: [],
                    source: 'unknown',
                };
            }

            // Using level resets, throw out old level start times (by marking the min start time)
            for (let reset_idx = 0; reset_idx < wkdata.resets.length; reset_idx++) {
                let reset = wkdata.resets[reset_idx];
                let reset_time = new Date(reset.confirmed_at);
                for (let level = reset.target_level; level <= wkof.user.level; level++) {
                    let level_time = level_times[level];

                    // Ignore resets that happened before this level-up.
                    if (reset_time < level_time.min) continue;

                    // Update the min start time.
                    level_time.min = reset_time;
                    delete level_times[level].reset_time;
                }
                level_times[reset.target_level].reset_time = reset_time;
            }

            // Using the newest levelup record for each level, set known start times.
            let oldest_levelup = {index: -1, time: new Date('2999-01-01')};
            for (let levelup_idx = 0; levelup_idx < wkdata.levelups.length; levelup_idx++) {
                let levelup = wkdata.levelups[levelup_idx];
                let level = levelup.level;
                if (level > wkof.user.level) continue;
                let unlocked_time = new Date(levelup.unlocked_at);
                let level_time = level_times[level];

                // Check if this is the oldest recorded level-up, which may be invalid.
                if (unlocked_time < oldest_levelup.time) {
                    oldest_levelup = {index: levelup_idx, time: unlocked_time, level: level};
                }

                // Ignore levelups that were invalidated by a reset.
                if (unlocked_time < level_time.min) continue;

                // Update the level start time.
                level_time.min = unlocked_time;
                level_time.source = 'APIv2 level_progressions';
                if (!levelup.abandoned_at && levelup.passed_at) {
                    level_time.max = new Date(levelup.passed_at);
                } else if (level === wkof.user.level) {
                    level_time.max = new Date();
                }
            }

            let items = wkdata.items;
            let level_progressions = wkdata.levelups;
            let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].unlocked_at;
            // Find indefinite level ups by looking at lesson history

            // Sort lessons by level then unlocked date
            items.forEach((item) => {
                if (
                    (item.object !== 'kanji' && item.object !== 'radical') ||
                    !item.assignments ||
                    !item.assignments.unlocked_at ||
                    item.assignments.unlocked_at >= first_recorded_date
                )
                    return;
                let date = new Date(item.assignments.unlocked_at);
                if (!level_times[item.data.level]) {
                    level_times[item.data.level] = {};
                }
                if (!level_times[item.data.level].dates[date.toDateString()]) {
                    level_times[item.data.level].dates[date.toDateString()] = [date];
                }
                else {
                    level_times[item.data.level].dates[date.toDateString()].push(date);
                }
            });
            // Discard dates with less than 10 unlocked
            // then discard levels with no dates
            // then keep earliest date for each level
            for (let [level, {min, max, dates, source}] of Object.entries(level_times)) {
                for (let [date, data] of Object.entries(dates)) {
                    if (data.length < 10)
                        delete dates[date];
                }
                if (Object.keys(level_times[level].dates).length === 0) {
                    delete level_times[level].dates;
                    continue;
                }
                //level_times[level].min = Object.values(dates).reduce((low, curr) => (low < curr ? low : curr), Date.now()).sort((a, b) => (a.getTime() - b.getTime()))[0];
                level_times[level].min = Object.values(dates).reduce((acc,item)=>{let smallest=item.reduce((a,b)=>a<b?a:b);return acc<smallest ? acc : smallest;}, new Date());
            }
            // Map to array of [[level0, date0], [level1, date1], ...] Format
            //levels = Object.entries(levels).map(([level, date]) => [Number(level), date]);

            // Add definite level ups from API
            Object.values(level_progressions).forEach(lev => {
                                                      if (level_times[lev.level].source === 'APIv2 level_progressions') return;
                                                      level_times[lev.level] = {
                                                          min: new Date(lev.unlocked_at),
                                                          max: (lev.passed_at ? new Date(lev.passed_at) : wkdata.load_time),
                                                          source: 'APIv2 level_progressions'
            };});

            for (let level = 1; level <= wkof.user.level; level++) {
                let level_data = level_times[level];
                if (level_data.source === 'APIv2 level_progressions') continue;
                if (level < level_times.length - 1) {
                    let next_level_data = level_times[level+1];
                    if (level_data.max.getTime() === wkdata.load_time.getTime())
                        level_data.max = next_level_data.min;
                }
            }

            // Calculate durations
            let durations = wkstats.level_durations = [];
            for (let level = 1; level <= wkof.user.level; level++) {
                let level_time = level_times[level];
                durations[level] = (level_time.max - level_time.min) / 86400000;
            }

            log('levelups','--[ Level-ups ]----------------');
            let level_durations = wkstats.level_durations;
            // Log the current level statuses.
            log('levelups','Started: '+yyyymmdd(wkof.user.started_at));
            if (wkof.user.restarted_at) {
                log('levelups','Restarted: '+yyyymmdd(wkof.user.restarted_at));
            }
            for (let level = 1; level <= wkof.user.level; level++) {
                let level_time = level_times[level];
                let level_duration = level_durations[level];
                if (level_time.reset_time) {
                    log('levelups','Reset');
                }
                // Flag any unusual level durations.
                if (level < wkof.user.level && (level_duration < 3.0 || level_duration > 2000))
                    log('levelups','###################');
                log('levelups','Level '+level+': ('+yyyymmdd(level_time.min)+' - '+yyyymmdd(level_time.max)+') - '+
                    duration(level_duration)+' (source: '+level_time.source+')');
            }
            wkof.set_state('wkof.wkstats.levelups', 'ready');
        };
        // Immediately run in order to overwrite all of the configurations from the default run
        calc_levelups();
    }
})();