[Userscript] WaniKani Review Clock

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

1 Like

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!

1 Like

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); });

})();
1 Like

Thank you for these updates! This fixed the current plugin at the top link which does not work with the current version of WaniKani