// ==UserScript==
// @name WaniKani Workload Graph
// @namespace rwesterhof
// @version 0.3
// @description adds a button to the heatmap that displays your average workload over time
// @match https://www.wanikani.com/
// @match https://preview.wanikani.com/
// @match https://www.wanikani.com/dashboard
// @match https://preview.wanikani.com/dashboard
// @require https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=852495
// @run-at document-end
// @grant none
// ==/UserScript==
(function(wkof, review_cache) {
'use strict';
/* global $, wkof */
if (!wkof) {
let response = confirm('WaniKani Workload Graph script requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
if (response) {
window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
}
return;
}
// --------------------------- //
// -------- TRIGGER ---------- //
// --------------------------- //
var init = false;
var graphButton =
'<li class="sitemap__section"><button id="headerGraphButton" class="sitemap__section-header" data-navigation-section-toggle="" data-expanded="false" aria-expanded="false" type="button" onclick="window.graphReviews()">'
+ '<span lang="ja">図表</span><span lang="en" class="font-sans">Graph</span>'
+ '</button></li>';
var heatmapButton =
'<button class="graph-button hover-wrapper-target" onclick="window.graphReviews()"'
// todo: put in css for class graph-button
+ 'style="height: 30px; width: 30px; background-color: transparent !important; outline: none !important; box-shadow: none !important; color: var(--color); border: none; left: 25px; top: -5px;">'
+ '<div class="hover-wrapper above">Workload graph</div>'
+ '<i class="icon-bar-chart"></i>'
+ '</button>';
// put graph button in header initially
$('#sitemap').prepend(graphButton);
// add event listener
$('.dashboard')[0].addEventListener('heatmap-loaded', moveButtonToHeatmap);
// eventlistener that moves the graph button to the heatmap once it is loaded
function moveButtonToHeatmap(event) {
$('#headerGraphButton').detach();
$('#heatmap .views .reviews.view').prepend(heatmapButton);
}
function graphReviews() {
if (!init) {
wkof.include('ItemData');
wkof.ready('ItemData')
.then(displayProgressPane)
.then(stripData)
.then(cookData)
.then(createGraph)
.then(displayGraph);
init = true;
}
else {
$('#workloadGraph').removeClass('hidden');
}
}
window.graphReviews = graphReviews;
// --------------------------- //
// --- OBJECTS & CONSTANTS --- //
// --------------------------- //
function getBlankStrippedData() {
return { lastReviewDate: 0, reviewDays: [], levelUps: [] };
}
const categories = [ -1, 0, 0, 0, 0, 1, 1, 2, 3, 4 ];
function toCategory(stage) {
return categories[stage];
}
function getBlankReviewDayObj() {
return { day: 0,
date: 0,
year: 0,
reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
reviewsPerCategory: [0, 0, 0, 0],
reviewsTotal: 0
};
}
function getBlankCookedDayObj() {
return { point: 0,
year: 0,
levelUp: 0,
nrOfDays: 0,
reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
reviewsPerCategory: [0, 0, 0, 0],
reviewsTotal: 0
};
}
const msInDay = 24 * 60 * 60 * 1000;
const maxWidth = 900;
const minWidth = maxWidth / 2;
const maxPointSize = 10;
const runningAverageDays = 7;
const xAxisPadding = 50;
const yAxisPadding = 50;
const categoryColor = [ "#dd0093", "#882d9e", "#294ddb", "#0093dd" ];
const signalLevel = 5;
// ------------------------------------ //
// ----- RETRIEVAL AND STRIPPING ------ //
// ------------------------------------ //
// strips the review_cache down to what is needed for the current script
async function stripData() {
var data = await Promise.all([review_cache.get_reviews(), wkof.ItemData.get_items('assignments, include_hidden')]);
var strippedData = getBlankStrippedData();
if (data.length < 1 || data[0].length < 1 || data[0][0].length < 1) return strippedData;
var reviews = data[0];
var minDate = new Date(data[0][0][0]);
minDate.setHours(0,0,0,0);
var minMs = minDate.getTime();
var curYear = 0;
var wkDays = 0;
var progressCounter = data[0].length;
var stripFunction = function(item) {
if (--progressCounter % 100000 == 50000) {
console.log("Processing... " + progressCounter + " reviews left");
}
var nextDateMs = minMs + (wkDays * msInDay);
while (item[0] > nextDateMs) {
var nextReviewDay = getBlankReviewDayObj();
nextReviewDay.date = nextDateMs;
var reviewYear = new Date(nextReviewDay.date).getFullYear();
if (reviewYear > curYear) {
nextReviewDay.year = reviewYear;
curYear = reviewYear;
}
wkDays++;
nextDateMs += msInDay;
nextReviewDay.day = wkDays;
strippedData.reviewDays.push(nextReviewDay);
}
var reviewDay = strippedData.reviewDays[wkDays-1];
reviewDay.reviewsPerStage[item[2]]++;
reviewDay.reviewsPerCategory[toCategory(item[2])]++;
reviewDay.reviewsTotal++;
strippedData.lastReviewDate = item[0];
};
reviews.forEach(stripFunction);
strippedData.levelUps = await get_level_ups(data[1]);
return strippedData;
}
// plain copy from the heatmap script - wonder if we can store this somewhere so it can be reused rather than refetched. Also
// requires retrieval of all the items from wkof.
// Get level up dates from API and lesson history
async function get_level_ups(items) {
let level_progressions = await wkof.Apiv2.get_endpoint('level_progressions');
let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].data.unlocked_at;
// Find indefinite level ups by looking at lesson history
let levels = {};
// Sort lessons by level then unlocked date
items.forEach(item => {
if (item.object !== "kanji" || !item.assignments || !item.assignments.unlocked_at || item.assignments.unlocked_at >= first_recorded_date) return;
let date = new Date(item.assignments.unlocked_at).toDateString();
if (!levels[item.data.level]) levels[item.data.level] = {};
if (!levels[item.data.level][date]) levels[item.data.level][date] = 1;
else levels[item.data.level][date]++;
});
// Discard dates with less than 10 unlocked
// then discard levels with no dates
// then keep earliest date for each level
for (let [level, data] of Object.entries(levels)) {
for (let [date, count] of Object.entries(data)) {
if (count < 10) delete data[date];
}
if (Object.keys(levels[level]).length == 0) {
delete levels[level];
continue;
}
levels[level] = Object.keys(data).reduce((low, curr) => low < curr ? low : curr, Date.now());
}
// 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(level => levels.push([level.data.level, new Date(level.data.unlocked_at).toDateString()]));
return levels;
}
// -------------------------------------- //
// ------------- PROCESSING ------------- //
// -------------------------------------- //
function getGraphPoint(cookedData, index) {
if (index >= cookedData.length) return null;
if (!cookedData[index]) {
cookedData[index] = getBlankCookedDayObj();
cookedData[index].point = index;
}
return cookedData[index];
}
function addArrayTotals(arrayOne, arrayTwo) {
if (arrayOne == null) return arrayTwo;
if (arrayTwo == null) return arrayOne;
if (arrayTwo.length < arrayOne.length) return addArrayTotals(arrayTwo, arrayOne);
for (var index = 0; index < arrayOne.length; index++) {
arrayOne[index] += arrayTwo[index];
}
return arrayOne;
}
// accumulate reviewdata to data points
function cookData(strippedData) {
var nrOfEntries = strippedData.reviewDays.length;
console.log("Scaling for " + nrOfEntries + " review days");
var daysPerPoint = Math.floor(nrOfEntries / maxWidth);
if ((nrOfEntries % maxWidth > 0) || (daysPerPoint == 0)) {
daysPerPoint++;
}
var totalPoints = Math.floor(nrOfEntries / daysPerPoint);
if (nrOfEntries % daysPerPoint > 0) {
totalPoints++;
}
var cookedData = new Array(totalPoints + 1);
var cookFunction = function(reviewDay) {
for (var countDay = reviewDay.day; countDay < reviewDay.day + runningAverageDays; countDay++) {
var graphPoint = Math.ceil(countDay / daysPerPoint);
var cookedDay = getGraphPoint(cookedData, graphPoint);
if (!cookedDay) break;
cookedDay.nrOfDays++;
cookedDay.reviewsPerStage = addArrayTotals(cookedDay.reviewsPerStage, reviewDay.reviewsPerStage);
cookedDay.reviewsPerCategory = addArrayTotals(cookedDay.reviewsPerCategory, reviewDay.reviewsPerCategory);
cookedDay.reviewsTotal += reviewDay.reviewsTotal;
if (countDay == reviewDay.day) {
if (reviewDay.year != 0) {
cookedDay.year = reviewDay.year;
}
const searchDate = new Date(reviewDay.date).toDateString();
let level = (strippedData.levelUps.find(a=>a[1]==searchDate) || [undefined])[0];
if (level) {
cookedDay.levelUp = level;
}
}
}
};
strippedData.reviewDays.forEach(cookFunction);
return cookedData;
}
// ---------------------------------------------- //
// -------------- DISPLAY ----------------------- //
// ---------------------------------------------- //
const graphDiv = '<div id="workloadGraph" class="" style="position: fixed; top: 100px; left: 100px; z-index: 1;"></div>';
const closeGraphButton = '<button id="closeGraph" onclick="window.hideGraph()" style="cursor:pointer; position:absolute; top: 10px; right: 10px; border: 0px;"><i class="icon-minus"></i></button>';
function hideGraph() {
$('#workloadGraph').addClass('hidden');
}
window.hideGraph = hideGraph;
function displayProgressPane() {
$('.srs-progress').before(graphDiv);
var graphCanvas = document.createElement("canvas");
graphCanvas.id="graphCanvas";
graphCanvas.width = 200;
graphCanvas.height = 200;
var progressPane = $('#workloadGraph');
progressPane.append(graphCanvas);
progressPane.removeClass('hidden');
clearGraph("Init..."); // turns out the graph refresh during processing doesn't want to trigger, so we log instead
}
// draws a single point on the canvas
function drawPoint(ctx, toX, toY, xAxisPointSize, fillInd, sameValue, firstPointInd) {
if (fillInd || (!firstPointInd && !sameValue)) {
ctx.lineTo(toX, toY);
}
else {
ctx.moveTo(toX, toY);
}
if (xAxisPointSize > 1) {
toX += xAxisPointSize - 1;
if (fillInd || !sameValue) {
ctx.lineTo(toX, toY);
}
else {
ctx.moveTo(toX, toY);
}
}
}
// clears the graph and optionally adds progress text
function clearGraph(text) {
var graphCanvas = $('#graphCanvas')[0];
var ctx = graphCanvas.getContext("2d");
ctx.save();
ctx.fillStyle="#f4f4f4";
ctx.fillRect(0, 0, graphCanvas.width, graphCanvas.height);
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.rect(0, 0, graphCanvas.width, graphCanvas.height);
ctx.stroke();
if (text) {
ctx.strokeText(text, xAxisPadding, yAxisPadding);
}
ctx.restore();
}
// display the graph on canvas
function displayGraph(graphData) {
// resize and empty the canvas for redrawing
var graphCanvas = $('#graphCanvas')[0];
graphCanvas.width = graphData.xAxisMaxSize + 2*xAxisPadding;
graphCanvas.height = graphData.yAxisMaxSize + 2*yAxisPadding;
clearGraph();
var ctx = graphCanvas.getContext("2d");
if (graphData.categoryPoints.length < 2) {
ctx.strokeText("No data", xAxisPadding, yAxisPadding);
return;
}
var fillInd = true;
ctx.save();
var baseY = graphCanvas.height - yAxisPadding;
for (var cat = categoryColor.length - 1; cat >= 0; cat--) {
var currentX = xAxisPadding;
ctx.fillStyle = categoryColor[cat];
ctx.strokeStyle = categoryColor[cat];
ctx.beginPath();
ctx.moveTo(currentX, baseY);
currentX++;
// nb: 0th entry is not a point!
for (var pointIndex = 1; pointIndex < graphData.categoryPoints.length; pointIndex++) {
var currentY = baseY - graphData.categoryPoints[pointIndex][cat];
var sameValue = (graphData.categoryPoints[pointIndex][cat] == ((cat > 0) ? graphData.categoryPoints[pointIndex][cat - 1] : 0));
drawPoint(ctx, currentX, currentY, graphData.xAxisPointSize, fillInd, sameValue, (pointIndex == 1));
currentX += graphData.xAxisPointSize;
}
if (fillInd) {
ctx.lineTo(currentX - 1, baseY);
ctx.lineTo(xAxisPadding, baseY);
ctx.fill();
}
else {
ctx.stroke();
}
}
// draw axes
ctx.strokeStyle = "#000000";
ctx.beginPath();
ctx.moveTo(xAxisPadding, baseY);
ctx.lineTo(xAxisPadding + graphData.xAxisMaxSize, baseY);
ctx.stroke();
var middleCorrection = Math.floor(graphData.xAxisPointSize / 2);
ctx.strokeStyle = "#007700";
for (var dashIndex = 0; dashIndex < graphData.xAxisYears.length; dashIndex++) {
var atX = xAxisPadding + graphData.xAxisYears[dashIndex][0] * graphData.xAxisPointSize - middleCorrection;
ctx.beginPath();
ctx.moveTo(atX, baseY);
ctx.lineTo(atX, baseY + 20);
ctx.stroke();
ctx.strokeText(graphData.xAxisYears[dashIndex][1], atX - 12, baseY + 35);
}
ctx.strokeStyle = "#000000";
for (dashIndex = 0; dashIndex < graphData.xAxisLevelUps.length; dashIndex++) {
atX = xAxisPadding + graphData.xAxisLevelUps[dashIndex][0] * graphData.xAxisPointSize - middleCorrection;
var levelLabel = graphData.xAxisLevelUps[dashIndex][1];
var signalInd = (levelLabel % signalLevel == 0);
ctx.beginPath();
ctx.moveTo(atX, baseY);
ctx.lineTo(atX, baseY + (signalInd ? 10 : 5));
ctx.stroke();
// add label every signalInd and for resets
if (signalInd || ((dashIndex > 0) && (levelLabel <= graphData.xAxisLevelUps[dashIndex - 1][1]))) {
ctx.strokeText(levelLabel, atX - 4, baseY + 20);
}
}
ctx.beginPath();
ctx.moveTo(xAxisPadding, baseY);
ctx.lineTo(xAxisPadding, baseY - graphData.yAxisMaxSize);
ctx.stroke();
var nrOfDashes = 3;
var yIndicatorsPer = Math.ceil(graphData.yAxisMaxValue / ((nrOfDashes+1) * 10)) * 10;
for (var dash = 1; dash <=nrOfDashes; dash++) {
var atY = baseY - ((yIndicatorsPer * dash) / graphData.yAxisPointValue);
if (atY < yAxisPadding) break;
ctx.beginPath();
ctx.moveTo(xAxisPadding, atY);
ctx.lineTo(xAxisPadding - 5, atY);
ctx.stroke();
ctx.strokeText(yIndicatorsPer * dash, xAxisPadding - 30, atY + 3);
}
// title
ctx.font = "bold 24px Arial, sans-serif";
ctx.fillStyle="#007700";
ctx.fillText("Workload - reviews per day", Math.floor(graphData.xAxisMaxSize / 2) - 160 + xAxisPadding, yAxisPadding - 10);
ctx.restore();
// allow closing of the graph
$('#workloadGraph').append(closeGraphButton);
}
// translate review datapoints to graph points
function createGraph(cookedData) {
var maxValue = 0;
var maxFinder = function(cookedDay) {
var newCandidate = Math.ceil(cookedDay.reviewsTotal / cookedDay.nrOfDays);
if (newCandidate > maxValue) {
maxValue = newCandidate;
}
}
cookedData.forEach(maxFinder);
// scaling of axes
var xAxisMaxSize = cookedData.length;
var xAxisPointSize = 1;
if (xAxisMaxSize < minWidth) {
xAxisPointSize = Math.ceil(minWidth / xAxisMaxSize);
xAxisMaxSize = xAxisPointSize * cookedData.length;
if (xAxisPointSize > maxPointSize) {
xAxisPointSize = maxPointSize;
xAxisMaxSize = minWidth;
}
}
var yAxisMaxSize = Math.ceil(xAxisMaxSize * 9 / 16); // take a standard aspect ratio
var yAxisMaxValue = Math.ceil(maxValue * 1.2 / 10) * 10; // let the values climb to abou 80% of the graph
var yAxisPointValue = yAxisMaxValue / yAxisMaxSize; // not an integer
// translate review information to graph points
var categoryPoints = new Array(cookedData.length);
var xAxisYears = new Array();
var xAxisLevelUps = new Array();
var pointFunction = function(cookedData) {
if (cookedData == null) return;
categoryPoints[cookedData.point] = [0, 0, 0, 0];
var divider = (cookedData.nrOfDays * yAxisPointValue);
categoryPoints[cookedData.point][0] = cookedData.reviewsPerCategory[0] / divider;
categoryPoints[cookedData.point][1] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1]) / divider;
categoryPoints[cookedData.point][2] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1] + cookedData.reviewsPerCategory[2]) / divider;
categoryPoints[cookedData.point][3] = (cookedData.reviewsPerCategory[0] + cookedData.reviewsPerCategory[1] + cookedData.reviewsPerCategory[2] + cookedData.reviewsPerCategory[3]) / divider;
if (cookedData.year != 0) {
xAxisYears.push([ cookedData.point, cookedData.year ]);
}
if (cookedData.levelUp != 0) {
xAxisLevelUps.push([ cookedData.point, cookedData.levelUp ]);
}
};
cookedData.forEach(pointFunction);
// return the accumulated graph data for drawing
return {
xAxisMaxSize: xAxisMaxSize,
xAxisPointSize: xAxisPointSize,
xAxisYears: xAxisYears,
xAxisLevelUps: xAxisLevelUps,
yAxisMaxSize: yAxisMaxSize,
yAxisMaxValue: yAxisMaxValue,
yAxisPointValue: yAxisPointValue,
categoryPoints: categoryPoints
};
}
})(window.wkof, window.review_cache);