WKStats Projections Page

Make a temporary projections page for WKStats

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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));
                    });
                });
            });
        });
    });
})();