// ==UserScript==
// @name WaniKani Review Clock
// @namespace wkreviewclock
// @description Adds a clock to WaniKani review session statistics and estimates the remaining time.
// @match http://www.wanikani.com/subjects/review
// @match https://www.wanikani.com/subjects/review
// @match http://www.wanikani.com/subjects/extra_study?queue_type=*
// @match https://www.wanikani.com/subjects/extra_study?queue_type=*
// @version 1.5
// @author Markus Tuominen
// @grant none
// @license GPL version 3 or later: http://www.gnu.org/copyleft/gpl.html
// @source https://github.com/Markus98/wk-review-clock
// ==/UserScript==
let statHtmlElems;
let time;
let startTime;
let rateShowDelay;
const timerTimeKey = 'reviewTimerTime';
const timerRateKey = 'reviewTimerRate';
const averageStatsKey = 'reviewRateAverageStats';
const scriptId = 'WKReviewClock';
const defaultSettings = {
units: 'rph',
location: 'toprightright',
showTimer: true,
showRate: true,
showRemaining: true,
updateInterval: 1.0,
enableRateShowDelay: false,
rateShowDelay: 5,
showTimeEstimate: true,
averageIgnorePeriod: 3,
};
function splitToHourMinSec(timeSec) {
const h = Math.floor( timeSec/60/60 );
const min = Math.floor( (timeSec - (h*60*60)) / 60 );
const sec = Math.round( timeSec - h*60*60 - min*60 );
return {h, min, sec};
}
function getTimeString(hourMinSec, includeSec=true) {
const { h, min, sec } = hourMinSec;
const hourString = h ? h+'h ' : '';
const minuteZero = h ? '0' : '';
const minuteString = (h||min||!includeSec) ? (minuteZero+min).slice(-2)+'m ' : '';
const secondZero = (h||min) ? '0' : '';
const secondString = (secondZero+sec).slice(-2)+'s';
return hourString + minuteString + (includeSec ? secondString : '');
}
function setCurrentTimerStats() {
// settings
let showTimer = defaultSettings.showTimer;
let showRate = defaultSettings.showRate;
let showRemaining = defaultSettings.showRemaining;
let hideRateRemaining = false;
if (window.wkof) {
showTimer = wkof.settings[scriptId].showTimer;
showRate = wkof.settings[scriptId].showRate;
showRemaining = wkof.settings[scriptId].showRemaining;
const enableRateShowDelay = wkof.settings[scriptId].enableRateShowDelay;
hideRateRemaining = enableRateShowDelay && time<rateShowDelay;
}
const hourMinSec = splitToHourMinSec(time);
if (showTimer) {
statHtmlElems.timer.getLabel().textContent = getTimeString(hourMinSec);
}
const reviewsDoneNumber = parseInt(
document.querySelector('[data-quiz-statistics-target="completeCount"]').textContent
);
const reviewRate = time !== 0 ? reviewsDoneNumber/time : 0; // reviews/sec
if (showRate) {
const formattedRate = formatRate(reviewRate, 'short');
statHtmlElems.rate.getLabel().textContent = (hideRateRemaining ? '—' : formattedRate) + '';
}
const reviewsAvailableNumber = parseInt(
document.querySelector('[data-quiz-statistics-target="remainingCount"]').textContent
);
const timeRemaining = reviewsAvailableNumber / reviewRate; // seconds
if (showRemaining) {
let remainingStr = 'Est. ';
if (hideRateRemaining) {
remainingStr += '—';
} else if (Number.isFinite(timeRemaining)) {
remainingStr += getTimeString(splitToHourMinSec(timeRemaining), false);
} else {
remainingStr += '∞';
}
statHtmlElems.remaining.getLabel().textContent = remainingStr;
}
// Set time and rate to localstorage for diplaying them later
window.localStorage.setItem(timerTimeKey, time);
window.localStorage.setItem(timerRateKey, reviewRate);
}
function generateStatHtmlElems() {
function genStatDiv(title, iconClassName, idSuffix) {
const statDiv = document.createElement('div');
const statDivId = 'wk-review-clock-markus98_stat-div-' + idSuffix;
statDiv.id = statDivId;
statDiv.title = title;
statDiv.className = 'quiz-statistics__item';
const statCountDiv = document.createElement('div');
statCountDiv.className = 'quiz-statistics__item-count';
const statCountIconDiv = document.createElement('div');
statCountIconDiv.className = 'quiz-statistics__item-count-icon';
const statIcon = document.createElement('i');
statIcon.className = iconClassName;
const statLabelDiv = document.createElement('div');
const labelId = 'wk-review-clock-markus98_label-' + idSuffix;
statLabelDiv.id = labelId;
statLabelDiv.className = 'quiz-statistics__item-count-text';
statLabelDiv.style.cssText = 'white-space: nowrap;';
statDiv.appendChild(statCountDiv);
statCountDiv.appendChild(statCountIconDiv);
statCountDiv.appendChild(statLabelDiv);
statCountIconDiv.appendChild(statIcon);
return {
div: statDiv,
getLabel: function () {
return document.getElementById(labelId);
},
getDiv: function () {
return document.getElementById(statDivId);
},
};
}
// Create statistics divs
const timer = genStatDiv('elapsed time', 'fa fa-clock-o', 'timer');
const rate = genStatDiv('review rate', 'fa fa-bolt', 'rate');
const remaining = genStatDiv('estimated remaining time', 'fa fa-clock-o', 'remaining');
statHtmlElems = {
timer: timer,
rate: rate,
remaining: remaining,
updateVisibility: function() {
if (!window.wkof) return;
const settings = wkof.settings[scriptId];
if (settings) {
const disp = (bool) => bool ? '' : 'display: none;';
this.timer.getDiv().style.cssText = disp(settings.showTimer);
this.rate.getDiv().style.cssText = disp(settings.showRate);
this.remaining.getDiv().style.cssText = disp(settings.showRemaining);
}
}
}
// append divs to appropriate parent
const location = window.wkof ? wkof.settings[scriptId].location : defaultSettings.location;
if (location == 'toprightright') {
const parent = document.getElementsByClassName('quiz-statistics')[0];
parent.appendChild(timer.div);
parent.appendChild(rate.div);
parent.appendChild(remaining.div);
} else if (location == 'toprightleft') {
const parent = document.getElementsByClassName('quiz-statistics')[0];
parent.prepend(remaining.div);
parent.prepend(rate.div);
parent.prepend(timer.div);
} else if (location == 'bottom') {
const parent = document.getElementById('additional-content');
const bottomMenu = document.createElement('div');
bottomMenu.style.cssText = 'display: flex; justify-content: center; padding: 10px;';
bottomMenu.appendChild(timer.div);
bottomMenu.appendChild(rate.div);
bottomMenu.appendChild(remaining.div);
bottomMenu.className = "additional-content__menu wkrc_bottom"
parent.append(bottomMenu);
}
statHtmlElems.updateVisibility();
}
function setStatsAndUpdateTime() {
time = (new Date() - startTime)/1000;
setCurrentTimerStats();
}
function startTimer (intervalSec) {
startTime = new Date();
setStatsAndUpdateTime();
setInterval(setStatsAndUpdateTime, intervalSec*1000);
}
function getAverageStats() {
const statsObj = JSON.parse(localStorage.getItem(averageStatsKey));
if (statsObj) {
return statsObj;
} else {
// default
return {
rateSum: 0,
reviews: 0,
mostRecentAdded: false
};
}
}
function setAverageStats(statsObj) {
localStorage.setItem(averageStatsKey, JSON.stringify(statsObj));
}
function setAverageRecentAdded(bool) {
const stats = getAverageStats();
stats.mostRecentAdded = bool;
setAverageStats(stats);
}
function startReviewTimer() {
// Start the timer
const interval = window.wkof ? parseFloat(wkof.settings[scriptId].updateInterval) : defaultSettings.updateInterval;
startTimer(interval);
setAverageRecentAdded(false);
}
/**
* @deprecated
*/
function showLastReviewStats() {
const footer = document.getElementById('last-session-date');
let ignoreInterval = defaultSettings.averageIgnorePeriod*60;
let showEstimatedSessionTime = defaultSettings.showTimeEstimate;
// Get settings if WK Open Framework is installed
if (window.wkof) {
ignoreInterval = parseFloat(wkof.settings[scriptId].averageIgnorePeriod)*60;
showEstimatedSessionTime = wkof.settings[scriptId].showTimeEstimate;
}
// Create divs and spans for stats in footer
const rateDiv = document.createElement('div');
const timeDiv = document.createElement('div');
const timeSpan = document.createElement('span');
const rateSpan = document.createElement('span');
timeDiv.appendChild(timeSpan);
rateDiv.appendChild(rateSpan);
const estimatedTimeDiv = document.createElement('div');
estimatedTimeDiv.style.cssText = 'font-size: 0.6em; position: relative; top: -70%;';
// Center text in review queue count
const reviewCountSpan = document.getElementById('review-queue-count');
reviewCountSpan.style.cssText += 'text-align: center;';
// Reset button
const resetAvgButton = document.createElement('button');
resetAvgButton.textContent = 'reset average';
resetAvgButton.style.cssText = 'font-size: 0.6em; color: inherit';
resetAvgButton.onclick = () => {
if (confirm('Are you sure you want to reset the average review rate?')) {
localStorage.removeItem(averageStatsKey);
location.reload();
}
};
// Saved time and rate
const lastTime = parseFloat(localStorage.getItem(timerTimeKey));
const lastTimeStr = isNaN(lastTime) ? '—' : getTimeString(splitToHourMinSec(lastTime));
const lastRate = parseFloat(localStorage.getItem(timerRateKey));
const lastRateStr = formatRate(lastRate);
// Average rate
const avgStats = getAverageStats();
if (!avgStats.mostRecentAdded && lastTime > ignoreInterval && lastRate > 0) {
avgStats.rateSum += lastRate;
avgStats.reviews += 1;
avgStats.mostRecentAdded = true;
setAverageStats(avgStats);
}
const avgRate = avgStats.rateSum / avgStats.reviews; // reviews/second
const avgRateStr = formatRate(avgRate, 'short');
// Estimate time for current reviews
const numOfReviews = parseInt(reviewCountSpan.textContent);
const estimatedTime = numOfReviews / avgRate;
const estimatedTimeStr = getTimeString(splitToHourMinSec(estimatedTime), false);
// Set stats text content
timeSpan.textContent = `Duration: ${lastTimeStr}`;
rateSpan.textContent = `Review rate: ${lastRateStr} (avg. ${avgRateStr}) (${avgStats.reviews} sessions)`;
estimatedTimeDiv.textContent =
!showEstimatedSessionTime || isNaN(estimatedTime) || numOfReviews === 0 ?
'' : `~${estimatedTimeStr}`;
// Append html elements to page
footer.appendChild(timeDiv);
footer.appendChild(rateDiv);
footer.appendChild(resetAvgButton);
reviewCountSpan.appendChild(estimatedTimeDiv);
}
let shortUnitNames = {'rph': 'r/h', 'rpm': 'r/m', 'mp100r': 'm/100r'}
let unitNames = {'rph': 'reviews/hr', 'rpm': 'reviews/min', 'mp100r': 'min/100 reviews'}
function formatRate(rps, format) {
if (isNaN(rps) || rps < 0.00001) {
return '—';
}
rps = parseFloat(rps);
const units = window.wkof ? wkof.settings[scriptId].units : defaultSettings.units;
let res;
if (units == 'rph') {
res = rps*3600;
} else if (units == 'rpm') {
res = rps*60;
} else if (units == 'mp100r') {
res = 1/rps/60*100;
}
if (format == 'short') {
return res.toFixed(1) + ' ' + shortUnitNames[units];
} else {
return res.toFixed(1) + ' ' + unitNames[units];
}
}
function openSettings() {
var config = {
script_id: scriptId,
title: 'Review Clock Settings',
on_save: () => {
wkof.Settings.save(scriptId);
statHtmlElems.updateVisibility();
rateShowDelay = parseFloat(wkof.settings[scriptId].rateShowDelay)*60;
},
content: {
general: {
type: 'page',
label: 'General',
content: {
units: {
type: 'dropdown',
label: 'Units for Speed',
default: defaultSettings.units,
hover_tip: 'What units the review rate of completion should be displayed in.',
content: {
rph: 'reviews/hr',
rpm: 'reviews/min',
mp100r: 'min/100 reviews',
}
},
}
},
reviewPage: {
type: 'page',
label: 'Review Page',
content: {
location: {
type: 'dropdown',
label: 'Display Location',
default: defaultSettings.location,
hover_tip: 'Where to show the below items (if checked) during reviews.',
content: {
toprightright: 'top right (right of other stats)',
toprightleft: 'top right (left of other stats)',
bottom: 'bottom in gray font',
}
},
showTimer: {
type: 'checkbox',
label: 'Show elapsed time',
default: defaultSettings.showTimer,
hover_tip: 'Show the elapsed time during a review session.',
},
showRate: {
type: 'checkbox',
label: 'Show review rate',
default: defaultSettings.showRate,
hover_tip: 'Show the review rate (reviews/hour).',
},
showRemaining: {
type: 'checkbox',
label: 'Show remaining time estimate',
default: defaultSettings.showRemaining,
hover_tip: 'Show the estimated remaining time based on the review rate and remaining items.',
},
divider1: {
type: 'divider'
},
updateInterval: {
type: 'number',
label: 'Statistics update interval (s)',
hover_tip: 'How often the statistic numbers should be updated (x second intervals).',
default: defaultSettings.updateInterval,
min: 0.01
},
rateShowDelayGroup: {
type: 'group',
label: 'Estimate Show Delay',
content: {
rateShowDelaySection: {
type: 'html',
html: 'Only show the review rate and remaining time estimate after the session is longer than a specified duration.'
},
enableRateShowDelay: {
type: 'checkbox',
label: 'Enabled',
default: defaultSettings.enableRateShowDelay,
hover_tip: 'Enable a delay in showing the rate and time estimate.'
},
rateShowDelay: {
type: 'number',
label: 'Duration (min)',
hover_tip: 'The number of minutes that the review rate and time estimate should be hidden for at the beginning of a session.',
default: defaultSettings.rateShowDelay,
min: 0
}
}
}
}
},
}
}
var dialog = new wkof.Settings(config);
dialog.open();
}
function installSettingsMenu() {
wkof.Menu.insert_script_link({
name: 'review_clock_settings',
submenu: 'Settings',
title: 'Review Clock',
on_click: openSettings
});
}
async function main() {
if (window.wkof) {
const wkof_modules = 'Settings,Menu';
wkof.include(wkof_modules);
await wkof.ready(wkof_modules)
.then(() => wkof.Settings.load(scriptId, defaultSettings))
.then(installSettingsMenu);
rateShowDelay = parseFloat(wkof.settings[scriptId].rateShowDelay)*60;
} else {
console.warn('WaniKani Review Clock: Wanikani Open FrameWork required for adjusting settings. '
+ 'Installation instructions can be found here: https://community.wanikani.com/t/installing-wanikani-open-framework/28549');
}
const style = document.createElement('style');
style.textContent = '.wkrc_bottom i { margin-right: 0.5em; margin-left: 0.8em; }' +
'.wkrc_bottom span { margin-right: 0.5em; }' +
'.wkrc_bottom { color:#BBB; letter-spacing: initial; display: block; text-align: center; }';
document.head.append(style);
if(/subjects\/(review|extra_study\?queue_type=.*)$/.exec(window.location.href)) { // review page
await generateStatHtmlElems();
startReviewTimer();
} else { // review summary page
// showLastReviewStats();
}
}
main();