// ==UserScript==
// @name WaniKani Timeline
// @namespace https://www.wanikani.com
// @description This is the WaniKani Customizer Chrome Extension Timeline ported to UserScript
// @version 0.1.2
// @include https://www.wanikani.com/
// @include https://www.wanikani.com/dashboard
// @include https://www.wanikani.com/account
// @run-at document-end
// @grant none
// ==/UserScript==
/*jslint browser: true, plusplus: true*/
/*global $, console, alert, confirm */
/*
Almost all of this code is by Kyle Coburn aka kiko on WakiKani.
It has been reformatted slightly and some minor changes made.
rev 0.1.1: added a few more options
for classic style: enable only fuzzy_time_mode_past, fuzzy_time_mode_near, and optionally twelve_hour_mode
rev 0.1.2:
change to fix server timeouts on higher level vocabulary
change to prevent always reloading data when no current reviews
increased max display time to 7-days
changed data storage format. now stores number and type of current and burning items. (for more display options later)
added a last updated time (works with 'WaniKani Real Times')
added a basic loading and error indicator
added 'wkt_username_' prefix to localStorage keys (for multiple WK account support)
added a reload button
added invalid API key detection. forget bad key. get new on refresh.
*/
(function () {
'use strict';
var level, localStoragePrefix, tRes, ajaxCompletedCount, api_calls, api_colors, curr_date, start_time, gHours, graphH, graphH_canvas, styleCss,
xOff, vOff, max_hours, times, pastReviews, firstReview, tFrac, page_width, options = {};
/* ### CONFIG OPTIONS ### */
// options.twelve_hour_mode = true; // enable 12-hour AM/PM mode
options.fuzzy_time_mode_past = true; // enable '-x mins' mode for items now available
// options.fuzzy_time_mode_near = true; // enable 'x mins' mode for upcoming items: now < time < now+90min
options.fuzzy_time_paren = true; // append (x mins) to time for items: time < now+90min
options.show_weekday = true; // show weekday prefix
options.enable_arrows = true; // enable indicator arrows
/* ### END CONFIG ### */
api_colors = ['#0096e7', '#ee00a1', '#9800e8'];
curr_date = new Date();
start_time = curr_date.getTime() / 1000;
gHours = 18;
graphH = 88;
graphH_canvas = graphH + 15;
xOff = 18;
vOff = 16;
max_hours = 24 * 7;
function strNumSeq(min, max) {
var i, str = '';
for (i = min; i <= max; i++) {
if (str) {
str += ',';
}
str += i;
}
return str;
}
function addSplitVocab(level) {
var segCnt, segLen, min, max,
vocabRequestLevelSplitSize = 20; // maximum number of levels per vocab API request
segCnt = Math.ceil(level / vocabRequestLevelSplitSize);
segLen = Math.ceil(level / segCnt);
for (min = 1; min <= level; min += segLen) {
max = min + segLen - 1;
if (max > level) {
max = level;
}
api_calls.push('vocabulary/' + strNumSeq(min, max));
}
}
function getDashboardLevel() {
var match, levelStr = $('section.progression h3').html();
if (levelStr) {
match = levelStr.match(/Level (\d+) /);
if (match && match.length === 2) {
return parseInt(match[1], 10);
}
}
return null;
}
api_calls = ['radicals', 'kanji'];
level = getDashboardLevel();
if (level && 0 < level && level < 100) { // allow for level expansion
addSplitVocab(level);
} else { // if unknown level fail to no-split
api_calls.push('vocabulary');
}
function getPageUser() {
var match, profileUrl = $('ul.nav a:contains("Profile")').prop('href');
if (profileUrl) {
match = profileUrl.match('[^/]*$');
if (match && match.length === 1) {
return match[0];
}
}
return ''; // blank if error
}
localStoragePrefix = 'wkt_' + getPageUser() + '_';
// Helpers
function pluralize(noun, amount) {
return amount + ' ' + (amount !== 1 ? noun + 's' : noun);
}
function fuzzyMins(minutes) {
var seconds;
if (minutes < 1 && minutes > 0) {
seconds = Math.round(minutes * 60);
return pluralize('sec', seconds);
}
minutes = Math.round(minutes);
return pluralize('min', minutes);
}
// Draw
function drawBarRect(ctx, xo, yo, bw, bh, color) {
ctx.fillStyle = color;
ctx.fillRect(xo, yo, bw, bh);
}
function drawBar(ctx, time, height, hOff, color, tFrac, outlined) {
var bx = xOff + time * tFrac, by = graphH - height - hOff;
ctx.fillStyle = color;
ctx.fillRect(bx, by, tFrac, height);
if (outlined) {
ctx.strokeStyle = (outlined === -1 ? '#ffffff' : '#000000');
//ctx.strokeRect(bx, by, tFrac, hOff === 0 ? graphH : height);
ctx.beginPath();
ctx.moveTo(bx, by + height);
ctx.lineTo(bx, by);
ctx.lineTo(bx + tFrac, by);
ctx.lineTo(bx + tFrac, by + height);
if (hOff !== 0) { // don't draw stroke on top of axis
ctx.lineTo(bx, by + height);
}
ctx.stroke();
}
}
function drawArrow(ctx, currentType, xOff, time, tFrac) {
if (currentType === -1) {
ctx.fillStyle = '#FF0000';
} else if (currentType === -2) {
ctx.fillStyle = '#A0A0A0';
} else {
return;
}
var bx = xOff + (time - 1) * tFrac,
topY = 3 + graphH,
halfWidthX = tFrac / 2,
cenX = bx + halfWidthX;
if (halfWidthX > 9) { // limit arrow width
halfWidthX = 9;
}
ctx.beginPath();
ctx.moveTo(cenX, topY);
ctx.lineTo(cenX - halfWidthX, topY + 10);
ctx.lineTo(cenX + halfWidthX, topY + 10);
ctx.fill();
//ctx.lineTo(cenX, topY);
//ctx.strokeStyle = "#000000";
//ctx.stroke();
}
function drawCanvas(clear) {
var totalCount, maxCount, graphTimeScale, ti, time, counts, total, ctx,
gTip, pidx, canvasJQ, currentType,
hrsFrac, gOff, height, count, i, width, hOff, xP,
canvas = document.getElementById('c-timeline'),
fuzzyExtra, weekdayText, weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
if (canvas.getContext) {
totalCount = 0;
maxCount = 3;
graphTimeScale = 60 * 60 * (gHours - 0.1);
if (gHours === 0) {
if (pastReviews) {
for (ti = 0; ti < 3; ++ti) {
totalCount += pastReviews[ti][0];
}
maxCount = totalCount;
}
} else {
for (time = 0; time < times.length; time++) {
if (time * 60 * tRes < graphTimeScale) {
counts = times[time];
if (counts) {
total = 0;
for (ti = 0; ti < 3; ++ti) {
total += counts[ti][0];
}
if (total > maxCount) {
maxCount = total;
}
totalCount += total;
}
}
}
}
if (totalCount === 0) {
maxCount = 0;
}
$('#g-timereviews').text(totalCount);
tFrac = tRes * (page_width - xOff) / 60 / gHours;
ctx = canvas.getContext("2d");
if (clear) {
ctx.clearRect(0, 0, page_width, graphH_canvas);
page_width = $('.span12 header').width();
} else {
gTip = $('#graph-tip');
canvasJQ = $('#c-timeline');
canvas.addEventListener('mousemove', function (event) {
var reviewCount, currentCount, burnCount, idx, showTime, minDiff, tDisplay, tDate, hours, mins, suffix, tText;
if (gHours === 0) {
return;
}
//~ idx = Math.floor((event.offsetX - xOff) / tFrac) + 1;
idx = Math.floor(((event.pageX - canvasJQ.offset().left) - xOff) / tFrac) + 1;
if (idx !== pidx) {
counts = times[idx];
if (counts) {
gTip.show();
reviewCount = counts[0][0] + counts[1][0] + counts[2][0];
currentCount = counts[0][1] + counts[1][1] + counts[2][1];
burnCount = counts[0][2] + counts[1][2] + counts[2][2];
showTime = counts[3] * 1000;
minDiff = (showTime - new Date().getTime()) / 1000 / 60;
if (options.fuzzy_time_mode_past && minDiff < 0) {
tDisplay = fuzzyMins(minDiff);
} else if (options.fuzzy_time_mode_near && 0 <= minDiff && minDiff < 90) {
tDisplay = fuzzyMins(minDiff);
} else {
tDate = new Date(showTime);
hours = tDate.getHours();
mins = tDate.getMinutes();
suffix = '';
if (options.twelve_hour_mode) {
suffix = ' ' + (hours < 12 ? 'am' : 'pm');
hours %= 12;
if (hours === 0) {
hours = 12;
}
}
if (hours < 10) {
hours = '0' + hours;
}
if (mins < 10) {
mins = '0' + mins;
}
weekdayText = '';
if (options.show_weekday) {
weekdayText = weekday[tDate.getDay()] + ' ';
}
fuzzyExtra = '';
if (options.fuzzy_time_paren && minDiff < 90) {
fuzzyExtra = ' (' + fuzzyMins(minDiff) + ')';
}
tDisplay = weekdayText + hours + ':' + mins + suffix + fuzzyExtra;
}
tText = tDisplay + '<br />' + pluralize('review', reviewCount);
if (currentCount) {
tText += '<br /><em>' + currentCount + ' current level</em>';
}
if (burnCount) {
tText += '<br /><em>' + burnCount + ' burning</em>';
}
gTip.html(tText);
gTip.css({
left: canvasJQ.position().left + idx * tFrac + xOff,
top: event.pageY - gTip.height() - 6
});
} else {
gTip.hide();
}
pidx = idx;
} else {
gTip.css('top', event.pageY - gTip.height() - 6);
}
}, false);
canvasJQ.mouseleave(function () {
gTip.hide();
pidx = null;
});
}
canvas.width = page_width;
hrsFrac = gHours / 3;
ctx.lineWidth = tFrac / 20;
ctx.strokeStyle = "#ffffff";
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
ctx.font = '12px sans-serif';
ctx.fillStyle = '#e4e4e4';
if (gHours !== 0) {
ctx.fillRect(0, Math.floor((vOff + graphH) * 0.5), page_width, 1);
}
ctx.fillRect(0, vOff - 1, page_width, 1);
ctx.fillStyle = '#505050';
ctx.textAlign = 'right';
ctx.fillText(maxCount, xOff - 4, vOff + 1);
// left border
ctx.fillStyle = '#d4d4d4';
ctx.fillRect(xOff - 2, 0, 1, graphH);
ctx.fillStyle = '#ffffff';
ctx.fillRect(xOff - 1, 0, 1, graphH);
// bottom border
ctx.fillStyle = '#d4d4d4';
ctx.fillRect(0, graphH, page_width, 1);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, graphH + 1, page_width, 1);
if (gHours === 0) {
if (pastReviews) {
gOff = xOff;
height = graphH - vOff;
for (ti = 0; ti < 3; ++ti) {
count = pastReviews[ti][0];
if (count > 0) {
width = Math.ceil(count / maxCount * (page_width - xOff));
drawBarRect(ctx, gOff, vOff, width, height, api_colors[ti]);
gOff += width;
}
}
}
} else {
for (i = 1; i < 4; ++i) {
xP = Math.floor(i / 3 * (page_width - 2));
if (i === 3) {
xP += 1;
} else if (page_width > 1100) {
--xP;
}
ctx.fillStyle = '#e4e4e4';
ctx.fillRect(xP, 0, 1, graphH_canvas);
ctx.fillStyle = '#505050';
ctx.fillText(String(hrsFrac * i), xP - 2, 0);
}
for (time = 0; time < times.length; time++) {
counts = times[time];
if (counts) {
hOff = 0;
currentType = 0;
if (counts[0][1] || counts[1][1] || counts[2][1]) {
currentType = -1;
} else if (counts[0][2] || counts[1][2] || counts[2][2]) {
currentType = -2;
}
if (currentType) {
drawBar(ctx, time - 1, graphH - vOff, 0, 'rgba(' + (currentType === -1 ? '255, 255, 255' : '0, 0, 0') + ', 0.33)', tFrac);
}
for (ti = 0; ti < 3; ++ti) {
count = counts[ti][0];
if (count > 0) {
height = Math.ceil(count / maxCount * (graphH - vOff));
drawBar(ctx, time - 1, height, hOff, api_colors[ti], tFrac, currentType);
hOff += height;
}
}
if (options.enable_arrows) {
drawArrow(ctx, currentType, xOff, time, tFrac);
}
}
}
}
}
}
function displayUpdateTime(date) {
var isoString, utcString;
isoString = date.toISOString();
utcString = isoString.replace(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.\d{3}Z/, '$1 $2 UTC');
$('section#r-timeline time').attr('datetime', isoString);
$('section#r-timeline time').attr('title', 'Updated: ' + utcString);
$('section#r-timeline time').html(utcString);
}
function initCanvas() {
var reviewHours = Math.ceil(firstReview / 60 / 60 / 6) * 6;
$('div#r-timeline-loading').hide();
$('#r-timeline').show();
if (reviewHours > gHours) {
gHours = reviewHours;
$('#g-range').attr('value', gHours); // set slider
$('#g-range').trigger('change'); // update slider text / draw
}
drawCanvas(); // need to init hover, even if redraw
displayUpdateTime(new Date(parseInt(localStorage.getItem(localStoragePrefix + 'cacheTime'), 10)));
}
function appendError(newError) {
var error = $('span#r-timeline-loading-error').html();
if (!error) {
error = 'Error: ';
} else {
error += ', ';
}
error += newError;
$('span#r-timeline-loading-error').html(error);
}
function ajaxError(xhr, ajaxOptions, thrownError) {
appendError(thrownError);
}
// Load data
function addData(data) {
var itemIdx, response, myLevel, firstItem, typeIdx, maxSeconds, item, stats, availableAt, tDiff, timeIdx, timeTable;
response = data.requested_information;
if (!response) {
if (data.error) {
if (data.error.code === "user_not_found") {
appendError('badApiKey');
localStorage.removeItem(localStoragePrefix + 'apiKey'); // remove invalid key
return;
}
}
appendError('badResponse');
return;
}
if (response.general) {
response = response.general;
}
myLevel = data.user_information.level;
firstItem = response[0];
typeIdx = firstItem.kana ? 2 : firstItem.important_reading ? 1 : 0;
maxSeconds = 60 * 60 * max_hours;
for (itemIdx = 0; itemIdx < response.length; itemIdx++) {
item = response[itemIdx];
stats = item.user_specific;
if (stats && !stats.burned) {
availableAt = stats.available_date;
tDiff = availableAt - start_time;
if (tDiff < maxSeconds) {
if (tDiff < firstReview) {
firstReview = tDiff;
}
timeIdx = tDiff < 1 ? -1 : Math.round(tDiff / 60 / tRes) + 1;
if (tDiff < 0) {
if (!pastReviews) {
pastReviews = []; // init object
}
timeTable = pastReviews;
availableAt = 0; // clear for past reviews
} else {
if (!times[timeIdx]) {
times[timeIdx] = []; // init object
}
timeTable = times[timeIdx];
}
if (!timeTable[0]) {
timeTable[0] = [0, 0, 0]; // 0:radical [0:total, 1:current, 2:burn]
timeTable[1] = [0, 0, 0]; // 1:kanji
timeTable[2] = [0, 0, 0]; // 2:vocab
timeTable[3] = availableAt; // 3:time
} else if (availableAt < timeTable[3]) {
timeTable[3] = availableAt;
}
timeTable[typeIdx][0]++; // add item to r0/k1/v2 bin total
if (typeIdx < 2 && item.level === myLevel && stats.srs === 'apprentice') {
timeTable[typeIdx][1]++; // increment current
} else if (stats.srs === 'enlighten') {
timeTable[typeIdx][2]++; // increment burn
}
}
}
}
ajaxCompletedCount++;
if (ajaxCompletedCount === api_calls.length && times && times.length > 0) {
localStorage.setItem(localStoragePrefix + 'reviewCache', JSON.stringify(times));
localStorage.setItem(localStoragePrefix + 'pastCache', JSON.stringify(pastReviews));
localStorage.setItem(localStoragePrefix + 'cacheTime', curr_date.getTime());
initCanvas();
} else {
$('span#r-timeline-loading-count').html(ajaxCompletedCount + '/' + api_calls.length);
if (ajaxCompletedCount >= api_calls.length) {
appendError('noData');
}
}
}
function insertTimeline() {
var apiKey, cacheExpires, ext, idx, counts;
ajaxCompletedCount = 0;
apiKey = localStorage.getItem(localStoragePrefix + 'apiKey');
if (apiKey && apiKey.length === 32) {
$('section.review-status').before('<div id="r-timeline-loading">Reviews Timeline Loading: <span id="r-timeline-loading-count"></span> <span id="r-timeline-loading-error" style="color:red"></span></div><section id="r-timeline" style="display: none;"><h4>Reviews Timeline</h4><a class="help">?</a> <a class="reload" title="reload (clear timeline cache)">R</a> <time class="timeago"></time><form id="graph-form"><label><span id="g-timereviews"></span> reviews <span id="g-timeframe">in ' + gHours + ' hours</span> <input id="g-range" type="range" min="0" max="' + max_hours + '" value="' + gHours + '" step="6" name="g-ranged"></label></form><div id="graph-tip" style="display: none;"></div><canvas id="c-timeline" height="' + graphH_canvas + '"></canvas></section>');
displayUpdateTime(new Date()); // normally overwritten later. workaround for 'Real Times' loading before data download.
$('span#r-timeline-loading-count').html(ajaxCompletedCount + '/' + api_calls.length);
try {
times = JSON.parse(localStorage.getItem(localStoragePrefix + 'reviewCache'));
pastReviews = JSON.parse(localStorage.getItem(localStoragePrefix + 'pastCache'));
} catch (ignore) {}
//~ if (times && pastReviews) {
if (times) {
cacheExpires = localStorage.getItem(localStoragePrefix + 'cacheTime');
if (cacheExpires && curr_date - cacheExpires > 60 * 60 * 1000) {
times = null;
}
}
//~ if (!times || !pastReviews || times.length === 0) {
if (!times || times.length === 0) {
times = null;
pastReviews = null;
localStorage.setItem(localStoragePrefix + 'reviewCache', null);
localStorage.setItem(localStoragePrefix + 'pastCache', null);
times = [];
firstReview = Number.MAX_VALUE;
for (ext = 0; ext < api_calls.length; ext++) {
$.ajax({
type: 'get',
url: '/api/v1.2/user/' + apiKey + '/' + api_calls[ext],
success: addData,
error: ajaxError
});
}
} else {
for (idx = 0; idx < times.length; idx++) {
counts = times[idx];
if (counts) {
firstReview = counts[3] - start_time;
break;
}
}
setTimeout(initCanvas, 0);
}
$('a.help').click(function () {
alert('Reviews Timeline - Displays your upcoming reviews\nY-axis: Number of reviews\nX-axis: Time (scale set by the slider)\n\nThe number in the upper left shows the maximum number of reviews in a single bar.\nWhite-backed bars indicate that review group contains radicals/kanji necessary for advancing your current level.');
});
$('a.reload').click(function () {
if (confirm('Reviews Timeline: Reload Confirmation\n\nClick OK to clear the cache and refresh the page.\n\nWarning:\nExcessive API requests may be blocked by the server.') === true) {
localStorage.removeItem(localStoragePrefix + 'reviewCache');
document.location.reload();
}
});
$('#g-range').change(function () {
gHours = $(this).val();
if (gHours < 6) {
gHours = pastReviews ? 0 : 3;
}
$('#g-timeframe').text(gHours === 0 ? 'right now' : 'in ' + gHours + ' hours');
drawCanvas(true);
});
} else {
alert('Hang on! We\'re grabbing your API key for the Reviews Timeline. We should only need to do this once.');
document.location.pathname = '/account';
}
}
// from: https://gist.githubusercontent.com/arantius/3123124/raw/grant-none-shim.js
function addStyle(aCss) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
return style;
}
return null;
}
styleCss = '\n' +
'#graph-tip {\n' +
' padding: 2px 8px;\n' +
' position: absolute;\n' +
' color: #eeeeee;\n' +
' background-color: rgba(0,0,0,0.5);\n' +
' border-radius: 4px;\n' +
' pointer-events: none;\n' +
' font-weight: bold;\n' +
'}\n' +
'section#r-timeline {\n' +
' overflow: hidden;\n' +
' margin-bottom: 0px;\n' +
' height: ' + (options.enable_arrows ? '130' : '117') + 'px;\n' +
'}\n' +
'form#graph-form {\n' +
' float: right;\n' +
' margin-bottom: 0px;\n' +
' min-width: 50%;\n' +
' text-align: right;\n' +
'}\n' +
'section#r-timeline h4 {\n' +
' clear: none;\n' +
' float: left;\n' +
' height: 20px;\n' +
' margin-top: 0px;\n' +
' margin-bottom: 4px;\n' +
' font-weight: normal;\n' +
' margin-right: 12px;\n' +
'}\n' +
'a.help, a.reload {\n' +
' font-weight: bold;\n' +
' color: rgba(0, 0, 0, 0.1);\n' +
' font-size: 1.2em;\n' +
' line-height: 0px;\n' +
'}\n' +
'a.help:hover, a.reload:hover {\n' +
' text-decoration: none;\n' +
' cursor: help;\n' +
' color: rgba(0, 0, 0, 0.5);\n' +
'}\n' +
'@media (max-width: 767px) {\n' +
' section#r-timeline h4 {\n' +
' display: none;\n' +
' }\n' +
'}\n' +
'.dashboard section.review-status ul li time {\n' +
' white-space: nowrap;\n' +
' overflow-x: hidden;\n' +
' height: 1.5em;\n' +
' margin-bottom: 0;\n' +
'}\n';
if (document.location.pathname === "/account") {
(function () {
var apiKey, alreadySaved;
apiKey = $('input[placeholder="Key has not been generated"]').val();
if (apiKey) {
alreadySaved = localStorage.getItem(localStoragePrefix + 'apiKey');
localStorage.setItem(localStoragePrefix + 'apiKey', apiKey);
console.log('WaniKani Timeline Updated API Key: ' + apiKey);
if (!alreadySaved) {
document.location.pathname = '/dashboard';
}
}
}());
} else {
page_width = $('.span12 header').width();
if (page_width) {
tRes = Math.round(1 / (page_width / 1170 / 15));
insertTimeline();
}
addStyle(styleCss);
}
console.log('WaniKani Timeline: script load end');
}());