Wow, thanks for the contribution! I’m out for the weekend but will definitely take a look once I return home. I’ll merge then it if I can’t find any issues
Sorry it took a while, but the PR is now merged and the script has been updated on GreasyFork! Thank you again for the contribution!
Does anyone know of a script that displays a timer for the current review item?
Can I make a small feature request?
I would like it if there was a pause button near the timer. I often find myself getting taken away on a distraction during reviews, and it would be helpful for me if I could pause the timer and then resume it again when I’ve returned to continue reviewing.
If I get inspired maybe I’ll try to make it myself and submit a PR, cause it should be pretty easy overall if my assumptions about the timer are correct without viewing the source. But for the time being, I’ll keep it as a request.
- Edit 1:
- I ended up doing it myself, but it’s too big of a change to make it a PR, cause I also fixed things like the Font Awesome icons no longer being available.
- Edit 2:
- Also made a quick and dirty fix to get it to work with turbo loading.
- Edit 3 & 4:
- Fixed the errors and issues that would occur when navigating to/from a non-review page.
- Edit 5:
- Updated the timer’s precision to be as reliable as the original version.
- Edit 6 & 7:
- Fix some small oversights and tried to improve the onclick handler’s inconsistent behavior.
- Edit 8:
- Major overhaul to fix the onclick not working when the text was clicked instead of the icon.
- Also updated it so that changing the display location setting doesn’t require a page refresh to take effect.
- Made a variety of other checks and optimizations along the way.
- Edit 9:
- Finally narrowed down the actual cause of the issue that caused the event listener to sometimes not work (i.e., WK Queue Manipulator oddity), and created a workaround for it.
Here’s my modified version:
// ==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/*
// @match https://www.wanikani.com/*
// @version 1.6
// @author Markus Tuominen (Modified by Inserio)
// @grant none
// @license GPL version 3 or later: http://www.gnu.org/copyleft/gpl.html
// @source https://github.com/Markus98/wk-review-clock
// ==/UserScript==
(function() {
'use strict';
let statHtmlElems;
let time = 0;
let rateShowDelay;
const pausableTimer = new PausableTimer(() => {
setStatsAndUpdateTime();
});
const timerTimeKey = 'reviewTimerTime';
const timerRateKey = 'reviewTimerRate';
const averageStatsKey = 'reviewRateAverageStats';
const scriptId = 'WKReviewClock';
const scriptClass = 'wk-review-clock-markus98';
const baseClass = 'quiz-statistics__item';
const defaultSettings = {
units: 'rph',
location: 'toprightright',
showTimer: true,
showRate: true,
showRemaining: true,
updateInterval: 1.0,
enableRateShowDelay: false,
rateShowDelay: 5,
showTimeEstimate: true,
averageIgnorePeriod: 3,
};
/**
* Self-adjusting interval to account for drifting
*
* @param {function} workFunc Callback containing the work to be done for each interval
* @param {int} interval Interval speed (in milliseconds)
*/
function PausableTimer(workFunc, interval=0) {
const that = this;
this.isRunning = false;
this.interval = interval;
this.start = function() {
if (!that.isRunning) {
that.isRunning = true;
that.startTime = Date.now() - that.elapsedTime;
that.timerId = setInterval(() => {
that.elapsedTime = Date.now() - that.startTime;
workFunc();
}, that.interval);
}
};
this.pause = function() {
if (that.isRunning) {
that.isRunning = false;
that.timerId = clearInterval(that.timerId);
}
};
this.reset = function() {
that.pause();
that.elapsedTime = 0;
};
}
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;
const sec = time / 1000;
if (window.wkof) {
showTimer = window.wkof.settings[scriptId].showTimer;
showRate = window.wkof.settings[scriptId].showRate;
showRemaining = window.wkof.settings[scriptId].showRemaining;
const enableRateShowDelay = window.wkof.settings[scriptId].enableRateShowDelay;
hideRateRemaining = enableRateShowDelay && sec<rateShowDelay;
}
const hourMinSec = splitToHourMinSec(sec);
if (showTimer) {
statHtmlElems.timer.getLabel().textContent = getTimeString(hourMinSec);
}
const reviewsDoneNumber = parseInt(
document.querySelector('[data-quiz-statistics-target="completeCount"]').textContent
);
const reviewRate = sec !== 0 ? reviewsDoneNumber/sec : 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
Promise.resolve().then(() => {
window.localStorage.setItem(timerTimeKey, time);
window.localStorage.setItem(timerRateKey, reviewRate);
});
}
function onPlayPauseListener(event) {
if (!statHtmlElems.timer.ids.includes(event.target?.id)) return;
if (pausableTimer.isRunning) {
pausableTimer.pause();
statHtmlElems.timer.getIcon().textContent = '▶️';
} else {
pausableTimer.start();
statHtmlElems.timer.getIcon().textContent = '⏸️';
}
}
function generateStatHtmlElems() {
if (statHtmlElems !== undefined) return;
function genStatDiv(title, iconText, idSuffix) {
const statDiv = document.createElement('div');
const statDivId = scriptClass + '_stat-div-' + idSuffix;
statDiv.classList.add(baseClass, scriptClass);
statDiv.title = title;
statDiv.id = statDivId;
const statCountDiv = document.createElement('div');
const statCountDivId = scriptClass + '_stat-count-' + idSuffix;
statCountDiv.classList.add(baseClass + '-count', scriptClass);
statCountDiv.id = statCountDivId;
const statCountIconDiv = document.createElement('div');
statCountIconDiv.classList.add(baseClass + '-count-icon', scriptClass);
const statIcon = document.createElement('span');
const statIconId = scriptClass + '_stat-icon-' + idSuffix;
statIcon.classList.add('icon', scriptClass);
statIcon.id = statIconId;
statIcon.textContent = iconText;
const statLabelDiv = document.createElement('div');
const labelId = scriptClass + '_label-' + idSuffix;
statLabelDiv.classList.add(baseClass + '-count-text', scriptClass);
statLabelDiv.id = labelId;
statDiv.appendChild(statCountDiv);
statCountDiv.appendChild(statCountIconDiv);
statCountDiv.appendChild(statLabelDiv);
statCountIconDiv.appendChild(statIcon);
return {
div: statDiv,
ids: [statDivId, statCountDivId, statIconId, labelId],
getDiv() { return document.getElementById(statDivId); },
getCountDiv() { return document.getElementById(statCountDivId); },
getIcon() { return document.getElementById(statIconId); },
getLabel() { return document.getElementById(labelId); },
};
}
// Create statistics divs
const timer = genStatDiv('elapsed time', '⏸️', 'timer');
const rate = genStatDiv('review rate', '⚡', 'rate');
const remaining = genStatDiv('estimated remaining time', '⏱️', 'remaining');
statHtmlElems = {
timer: timer,
rate: rate,
remaining: remaining,
updateVisibility() {
if (!window.wkof) return;
const settings = window.wkof.settings[scriptId];
if (settings) {
this.timer.getDiv().classList.toggle('hidden', !settings.showTimer);
this.rate.getDiv().classList.toggle('hidden', !settings.showRate);
this.remaining.getDiv().classList.toggle('hidden', !settings.showRemaining);
}
},
parent: null
};
// statHtmlElems.timer.div.addEventListener('click', onPlayPauseListener, {passive: true}); // WK Queue Manipulator interferes with this
}
// append divs to appropriate parent
function addOrUpdateElemsOnPage() {
const timerDiv = statHtmlElems.timer.getDiv() || statHtmlElems.timer.div;
const rateDiv = statHtmlElems.rate.getDiv() || statHtmlElems.rate.div;
const remainingDiv = statHtmlElems.remaining.getDiv() || statHtmlElems.remaining.div;
const location = window.wkof ? window.wkof.settings[scriptId].location : defaultSettings.location;
let parent;
statHtmlElems.parent?.removeEventListener('click', onPlayPauseListener, {passive: true});
switch (location) {
case 'toprightright':
parent = document.getElementsByClassName('quiz-statistics')[0];
parent.appendChild(timerDiv);
parent.appendChild(rateDiv);
parent.appendChild(remainingDiv);
statHtmlElems.parent = parent.parentElement;
if (statHtmlElems.bottomMenu) statHtmlElems.bottomMenu.remove();
break;
case 'toprightleft':
parent = document.getElementsByClassName('quiz-statistics')[0];
parent.prepend(remainingDiv);
parent.prepend(rateDiv);
parent.prepend(timerDiv);
statHtmlElems.parent = parent.parentElement;
if (statHtmlElems.bottomMenu) statHtmlElems.bottomMenu.remove();
break;
case 'bottom':
if (!statHtmlElems.bottomMenu) {
statHtmlElems.bottomMenu = document.createElement('div');
statHtmlElems.bottomMenu.classList.add("additional-content__menu", "wkrc_bottom", scriptClass);
}
parent = statHtmlElems.bottomMenu;
parent.appendChild(timerDiv);
parent.appendChild(rateDiv);
parent.appendChild(remainingDiv);
statHtmlElems.parent = parent;
document.getElementById('additional-content').append(parent);
break;
}
statHtmlElems.updateVisibility();
// workaround WK Queue Manipulator replacing the '.quiz-statistics' element with a clone
statHtmlElems.parent.addEventListener('click', onPlayPauseListener, {passive: true});
}
function setStatsAndUpdateTime() {
if (!statHtmlElems.timer.getDiv()) {
pausableTimer.pause();
return;
}
time = pausableTimer.elapsedTime;
setCurrentTimerStats();
}
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 intervalSec = window.wkof ? parseFloat(window.wkof.settings[scriptId].updateInterval) : defaultSettings.updateInterval;
pausableTimer.interval = intervalSec*1000;
pausableTimer.reset();
pausableTimer.start();
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(window.wkof.settings[scriptId].averageIgnorePeriod)*60;
showEstimatedSessionTime = window.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 = parseInt(localStorage.getItem(timerTimeKey))/1000;
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 ? window.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: (updatedSettings) => {
const prevLocation = defaultSettings.location;
Object.assign(defaultSettings, updatedSettings);
if (prevLocation !== updatedSettings.location)
addOrUpdateElemsOnPage();
else
statHtmlElems.updateVisibility();
pausableTimer.interval = parseFloat(updatedSettings.updateInterval)*1000;
if (pausableTimer.isRunning) {
pausableTimer.pause();
pausableTimer.start();
}
rateShowDelay = parseFloat(updatedSettings.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 window.wkof.Settings(config);
dialog.open();
}
function installSettingsMenu() {
window.wkof.Menu.insert_script_link({
name: 'review_clock_settings',
submenu: 'Settings',
title: 'Review Clock',
on_click: openSettings
});
}
function addStyle() {
if (document.getElementById(scriptId+'-style')) return;
const style = document.createElement('style');
style.setAttribute('id', scriptId+'-style');
style.textContent = `.${scriptClass} { cursor: default; }` +
`.${scriptClass}.hidden { display: none; }` +
`.${scriptClass}.${baseClass}-count-text { white-space: nowrap; }` +
`.${scriptClass}.${baseClass} span.icon { font-family: 'Segoe UI Emoji'; }` +
`.${scriptClass}.additional-content__menu.wkrc_bottom { color:#BBB; letter-spacing: initial; text-align: center; display: flex; justify-content: center; padding: 10px; }` +
`.${scriptClass}.wkrc_bottom span.icon { margin-right: 0.5em; margin-left: 0.8em; }` +
`.${scriptClass}.wkrc_bottom span { margin-right: 0.5em; }`
;
document.head.append(style);
}
function initWkof() {
if (window.wkof) {
const wkof_modules = 'Settings,Menu';
window.wkof.include(wkof_modules);
return window.wkof.ready(wkof_modules)
.then(async () => Object.assign(defaultSettings, await window.wkof.Settings.load(scriptId, defaultSettings)))
.then(installSettingsMenu)
.then(() => {rateShowDelay = parseFloat(window.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');
return Promise.resolve();
}
}
function main(url) {
initWkof().then(() => {
if(/subjects\/(review|extra_study\?queue_type=.*)$/.exec(url)) { // review page
addOrUpdateElemsOnPage();
startReviewTimer();
} else { // review summary page
// showLastReviewStats();
}
});
}
addStyle();
generateStatHtmlElems();
if (window.Turbo?.session.history.pageLoaded) {
main(window.location.href);
console.log('Added because page was loaded');
}
document.documentElement.addEventListener('turbo:load', (event) => { setTimeout(() => main(event.detail.url), 0); });
})();
Thank you for these updates! This fixed the current plugin at the top link which does not work with the current version of WaniKani