// ==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));
});
});
});
});
});
})();