WKStats Projections Page

Make a temporary projections page for WKStats

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WKStats Projections Page
// @version      1.4.1
// @description  Make a temporary projections page for WKStats
// @author       UInt2048
// @match        https://www.wkstats.com/*
// @run-at       document-end
// @grant        none
// @namespace https://greasyfork.org/users/684166
// ==/UserScript==

//jshint esversion:6
/*eslint max-len: ["error", { "code": 120 }]*/

(function() {
    "use strict";

    Date.prototype.add = function(seconds) {
        return this.setTime(this.getTime() + (seconds*1000)) && this;
    };

    Date.prototype.subtractDate = function(date) {
        return (this.getTime() - date.getTime()) / 1000;
    };

    Date.prototype.format = function() {
        return new window.Intl.DateTimeFormat("default", {
            year: 'numeric', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric', second: 'numeric'}).format(this);
    };

    const P = {
        maxLevel: null,
        progressions: [],
        stats: null,
        now: null,

        addGlobalStyle: function addGlobalStyle() {
            const head = document.getElementsByTagName("head")[0], css = `
.main-content .chart table.coverage {margin-top:1em; position:relative;}
.main-content .chart table.coverage {border-collapse:collapse; border-spacing:0; margin-left:auto; margin-right:auto;}
.main-content .chart table.coverage tr {border-left:1px solid #000;}
.main-content .chart table.coverage tr:first-child {border-top:1px solid #000;}
.main-content .chart table.coverage tr:last-child {border-bottom:1px solid #000;}
.main-content .chart table.coverage tr.header {background-color:#ffd; font-weight:bold;}
.main-content .chart table.coverage tr.header:nth-child(2) {line-height:1em;}
.main-content .chart table.coverage tr.header.bottom {border-bottom:1px solid #000;}
.main-content .chart table.coverage tr:not(.header) + tr:not(.header):not(.current_level) {border-top:1px solid #ddd;}
.main-content .chart table.coverage tr:not(.header):nth-child(even) {background-color:#efe;}
.main-content .chart table.coverage td {padding:0 .5em;}
.main-content .chart table.coverage td:first-child {border-right:1px solid #000;}
.main-content .chart table.coverage td:last-child {border-right:1px solid #000;}
.main-content .chart table.coverage tr.header td.header_div {border-bottom:1px solid #0001;}
.main-content .chart table.coverage tr.count td {font-weight:normal; font-size:0.625em;}
.main-content .chart table.coverage tr.current_level {border:2px solid #000;}
.main-content .chart table.coverage tr.current_level:after
{content:"\f061"; font-family:FontAwesome; position:absolute; display:inline-block; left:-1.25em;}
            `;
            if (head) {
                const style = document.createElement("style");
                style.type = "text/css";
                style.innerHTML = css.replace(/;/g, " !important;");
                head.appendChild(style);
            }
        },

        median: function median(arr) {
            const mid = Math.floor(arr.length / 2);
            return arr.length === 0 ? 0 : arr.length % 2 !== 0 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2;
        },

        countComponent: function(componentLevel, itemLevel) {
            // For items in future levels, don't count passing time for components on preceding levels
            return !(itemLevel > P.progressions[P.progressions.length - 1].level && componentLevel < itemLevel);
        },

        levelDuration: function(level) {
            return new Date(level.passed_at || level.abandoned_at).subtractDate(new Date(level.unlocked_at));
        },

        getLater: function(a, b) {
            return new Date(Math.max(a, b));
        },

        getFools: function(date, fools) {
            return fools ? new Date(date.getFullYear() + (date.getMonth() >= 3), 3, 1) : date;
        },

        get: function(a, b) {
            return a && a[b];
        },

        getID: function(a, b) {
            return P.get(document.getElementById(a), b);
        },

        getHyp: function(fastest, isCurrent) {
            const s = isCurrent ? "current" : fastest;
            return P.getID("speed" + (P.getID("hypothetical", "checked") ? "-" + s : ""), "value") * 3600 || 864000;
        },

        formatInterval: function(seconds) {
            const days = seconds / 86400;
            const hours = (days % 1) * 24;
            const minutes = (hours % 1) * 60;
            const secs = (minutes % 1) * 60;
            return `${Math.floor(days)}d ${Math.floor(hours)}h ${Math.floor(minutes)}m ${Math.floor(secs)}s`;
        },

        findLevel: function(levels, level) {
            return levels.slice().reverse().find(p => level == p.level);
        },

        rangeFormat: function(arr) {
            return arr.map((n, i) => i < arr.length - 1 && arr[i + 1] - n === 1 ?
                           `${i > 0 && n - arr[i - 1] === 1 ? "" : n}-` : `${n}, `
        ).join("").replace(/-+/g, "-").slice(0, -2);
    },

        project: function project() {
            const current = P.progressions[P.progressions.length - 1];
            const levels = P.progressions.slice().concat(Array.from({length: P.maxLevel - current.level + 2},
                                                                    (_, i) => ({level: current.level + 1 + i})));
            const medianSpeed = P.median(P.progressions.slice(0, -1).map(P.levelDuration).sort((a, b) => a - b));
            const showPast = P.getID("showPast", "checked");
            const fools = P.getID("fools", "checked");
            const hypothetical = P.getID("hypothetical", "checked");
            const time = P.stats.map(d => d.length && d.sort((a, b) => a[0] - b[0])[Math.ceil(d.length * 0.9) - 1][0]);
            const expanded = P.getID("expand", "checked") && P.findLevel(levels, P.getID("expanded", "value"));
            const u = time.map((d, i) => {
                const unlocked = P.get(P.findLevel(levels, i), "unlocked_at");
                return [(unlocked ? P.now.subtractDate(new Date(unlocked)) : 0) + d, i];
            });

            let output = `<input type="checkbox" id="expand" class="project" ${expanded ? "checked" : ""}>
            <label for="speed">Show Details for Level:</label>
            <input type="number" id="expanded" size="3" value="${P.get(expanded, "level") || current.level}"><br/>
            <input type="checkbox" id="showPast" class="project" ${showPast ? "checked" : ""}>
            <label for="showPast">Show Past Levels</label><br/>
            <input type="checkbox" id="fools" class="project" ${fools ? "checked" : ""}>
            <label for="fools">Dark Blockchain</label><br/>
            <input type="checkbox" id="hypothetical" class="project" ${hypothetical ? "checked" : ""}>
            <label for="hypothetical">Expand Hypothetical</label><br/>
            ${hypothetical ? Array.from(new Set(u.slice(current.level, -1).map(d => d[0]))).map((time, i) => {
                const s = i === 0 ? "current" : time;
                return `<label for="speed-${s}">Hypothetical Speed for fastest ${P.formatInterval(time)}
                (levels ${P.rangeFormat(u.filter((d, i) => time === d[0]).map(d => d[1]))}):</label>
                <input type="number" id="speed-${s}" size="4" value="${P.getHyp(time, i === 0) / 3600}">h<br/>`;
            }).reduce((a, b) => a + b) : `<label for="speed">Hypothetical Speed:</label>
            <input type="number" id="speed" size="4" value="${P.getHyp(time) / 3600}">h`}
            <button id="project" class="project">Project</button><br/>
            <table class="coverage"><tbody><tr class="header"> ${expanded ?
            "<td>Kanji</td><td colspan=3>Fastest</td>" :
        "<td>Level </td><td> Real/Predicted </td><td> Fastest </td><td> Hypothetical</td>"}</tr>`,
            unlocked = new Date(P.now), currentReached = false, info = "",
            real = null, fastest = null, given = null, p = [];

        for (const i of levels) {
            if (i === current) currentReached = true;
            if (!showPast && !currentReached) continue;

            const l = i.level,
                  _fastest = new Date(fastest || P.now).add(time[l - 1]),
                  _real = P.getLater(new Date(real || unlocked).add(l === P.maxLevel + 2 ? time[l - 1] : medianSpeed),
                                     _fastest),
                  _given = P.getLater(new Date(given || unlocked).add(l === P.maxLevel + 2 ? time[l - 1] :
                                                                      P.getHyp(time[l - 1], l === current.level + 1)),
                                      _fastest);

            if (i.unlocked_at) {
                unlocked = new Date(i.unlocked_at);
                info = `<td> ${unlocked.format()} </td><td> - </td><td> - </td>`;
            } else if (l <= P.maxLevel) {
                p[l] = {fastest: P.getFools(fastest = _fastest, fools).format(),
                        real: (real = _real).format(),
                        given: (given = _given).format()};
                info = `<td> ${p[l].real} </td><td> ${p[l].fastest} </td><td> ${p[l].given} </td>`;
            } else {
                p[l] = {fastest: P.getFools(_fastest, fools).format(),
                        real: _real.format(),
                        given: _given.format()};
                info = `<td> ${p[l].real} </td><td> ${p[l].fastest} </td><td> ${p[l].given} </td>`;
            }

            if (!expanded) {
                output += `<tr ${i === current ? "class=\"current_level\"" : ""}><td> ${
                l === P.maxLevel + 2 ? "全火" : String("0" + l).slice(-2)} </td> ${info} </tr>`;
            } else if (expanded === i) {
                for (const kan of P.stats[expanded.level]) {
                    const date = (kan[0] < 0 ? "Passed on " : "") + (new Date(fastest || P.now)).add(kan[0]).format();
                    output += `<tr><td>${kan[1].data.characters}</td><td colspan=3>${date}</tr>`;
                }
            }
        }

        output += "</tbody></table>";

        const element = document.getElementsByClassName("projections")[0];
        if (!element.className.includes("chart")) element.className += " chart";
        element.innerHTML = output;
        Array.from(document.getElementsByClassName("project")).forEach(x => x.addEventListener("click", project));
        P.addGlobalStyle();

        return JSON.stringify(Object.assign({}, p));
    },

        api: function api(userData, levels, systems, items) {
            if (P.progressions.length > 0) return P.project();

            P.maxLevel = userData.subscription.max_level_granted;
            P.progressions = Object.values(levels).map(level => level.data);
            P.now = new Date();

            const time = function(item, burn) {
                if (!P.get(item.assignments, burn ? "burned_at" : "passed_at")) {
                    let interval = P.get(item.assignments, "available_at") ?
                        Math.max(0, (new Date(item.assignments.available_at)).subtractDate(P.now)) : 0;
                    const srs = systems[item.data.spaced_repetition_system_id].data;
                    const target = P.get(srs, burn ? "burning_stage_position" : "passing_stage_position");
                    for (let i = (P.get(item.assignments, "srs_stage") || 0) + 1; i < target; i++) {
                        interval += srs.stages[i].interval;
                    }
                    return interval;
                }
                return (new Date(P.get(item.assignments, burn ? "burned_at" : "passed_at"))).subtractDate(P.now);
            };

            const unlock = function(item, itemLevel, burn) {
                return P.countComponent(item.data.level, itemLevel) ?
                    (item.object === "radical" || item.object === "kana_vocabulary" ? 0 : item.data.component_subject_ids.
                     map(id => Math.max(0, unlock(items.find(o => o.id === id), item.data.level))).
                     reduce((a, b) => Math.max(a, b))) + time(item, burn) : 0;
            };

            P.stats = Array.from(Array(P.maxLevel + 1), () => []);
            for (const item of items) {
                if (item.data.hidden_at || item.object !== "kanji") continue;
                P.stats[item.data.level].push([unlock(item, item.data.level, false), item]);
            }

            let burnStats = items.filter(item => !item.data.hidden_at).map(item => unlock(item, item.data.level, true));
            P.stats.push([[burnStats.sort((a, b) => a - b)[burnStats.length - 1], burnStats]]);

            return P.project();
        }
    };

    window.wkof.include("ItemData, Apiv2");
    window.wkof.ready("ItemData, Apiv2").then(() => {
        window.wkof.Apiv2.get_endpoint("user").then(userData => {
            window.wkof.Apiv2.get_endpoint("level_progressions").then(levels => {
                window.wkof.Apiv2.get_endpoint("spaced_repetition_systems").then(systems => {
                    window.wkof.ItemData.get_items("subjects, assignments").then(items => {
                        // Enable callback when we enter the progression page
                        window.wkof.on("wkstats.projections.loaded", () => P.api(userData, levels, systems, items));
                    });
                });
            });
        });
    });
})();