Greasy Fork is available in English.

Wanikani: Real (Time) Numbers

Updates the review count automatically as soon as new reviews are due

// ==UserScript==
// @name         Wanikani: Real (Time) Numbers
// @namespace    http://tampermonkey.net/
// @version      1.2.2
// @description  Updates the review count automatically as soon as new reviews are due
// @author       Kumirei
// @include      /^https://(www|preview).wanikani.com/(lesson/*|review/*|dashboard)?$/
// @grant        none
// ==/UserScript==
/*jshint esversion: 8 */

// @include for /review/* and /lesson/* is required because otherwise, the script will not run on the /review summary
// page if it was navigated to automatically upon completion of reviews/lesson (still works without them if navigating there directly)

(function() {
    let script_name = "Real (Time) Numbers";
    // Make sure WKOF is installed
    if (!wkof) {
        let response = confirm(script_name+' 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;
    }
    wkof.include('Apiv2');
    wkof.ready('Apiv2').then(init);

    function init() {
        // Wait until the top of the hour then update the review/lessons count
        let tpu = new PendingUpdater(true,45*1000); // no caching, look 45 seconds into the future to account for out of sync clocks
        wait_until(get_next_hour(), fetch_and_update_recurring);

        // Fetches the review/lessons counts, updates the dashboard, then does the same thing on top of every hour
        function fetch_and_update_recurring() {
            tpu.fetch_and_update();
            wait_until(get_next_hour(), fetch_and_update_recurring);
        }

        // Also update lessons/reviews whenever page is switched to
        let lastVisibilityState = 'visible';
        let vpu = new PendingUpdater(false, 0); // allow caching, no looking into the future
        document.addEventListener("visibilitychange", function() {
            if (document.visibilityState == 'visible' && lastVisibilityState == 'hidden') {
                vpu.fetch_and_update();
            }
            lastVisibilityState = document.visibilityState;
        });

        // Also update lessons/reviews whenever network status changes to online
        window.addEventListener('online',  function () {
            vpu.fetch_and_update();
        });

        // Add CSS
        let css = `.lessons-and-reviews__reviews-button, .lessons-and-reviews__lessons-button,
        navigation-shortcut--reviews, navigation-shortcut--lessons {
            transition: background 300ms;
        }`;
        add_css(css, 'real-time-numbers-css');

        // Also update lessons/reviews immediately if page was navigated to using back/forward button
        // must be run after css or else fade won't happen if this results in an update
        let nav = window.performance.getEntriesByType('navigation');
        if (nav.length > 0 && nav[0].type == 'back_forward') {
            vpu.fetch_and_update();
        }
    }

    // Waits until a given time and executes the given function
    function wait_until(time, func) {
        setTimeout(func, time - Date.now());
    }

    // Gets the time for the next hour in ms
    function get_next_hour() {
        let current_date = new Date();
        return new Date(current_date.toDateString() + ' ' + (current_date.getHours()+1) + ':').getTime();
    }

    // Adds CSS to document
    // Does not escape its input
    function add_css(css, id="") {
        document.getElementsByTagName('head')[0].insertAdjacentHTML('beforeend', `<style id="${id}">${css}</style>`);
    }

    // Handles fetching and displaying updates to pending lesson and review counts
    class PendingUpdater {
        // force_update (bool): when true, don't use cached data even if
        // age of cached data is < 60 seconds (default: false)
        // dt (number): # of ms to look ahead into the future when computing
        // what reviews/lessons are/will be available (default: 0)
        constructor(force_update, dt) {
            if (typeof(force_update) == 'undefined')
                force_update = false;
            if (typeof(dt) == 'undefined')
                dt = 0;
            this.force_update = force_update;
            this.dt = dt;
            this.thresholds = {reviews: [0,1,50,100,250,500,1000], // thresholds where reviews button image changes
                               lessons: [0,1,25,50,100,250,500]}; // thresholds where lessons button image changes
            this.threshold_cls_prefix = {reviews: "lessons-and-reviews__reviews-button--",
                                         lessons: "lessons-and-reviews__lessons-button--"};
        }

        // Fetches the review/lessons counts, updates the counts on the page
        fetch_and_update() {
            this.fetch_pending_counts()
                .then(this.update_pending_counts.bind(this));
        }

        // Retreives the number of reviews/lessons due
        async fetch_pending_counts() {
            let data = await wkof.Apiv2.get_endpoint('summary', {force_update: this.force_update});
            return {reviews: this.get_pending(data.reviews).length,
                    lessons: this.get_pending(data.lessons).length};
        }

        // Given a list of reviews/lessons returned from the api,
        // Returns available pending reviews/lessons as of current time + this.dt
        get_pending(lst) {
            let pending = [];
            let reference_time = Date.now() + this.dt;
            for (let i=0; i<lst.length; i++) {
                if (Date.parse(lst[i].available_at) <= reference_time)
                    pending.push(...lst[i].subject_ids);
            }
            return pending;
        }

        // Update both the review and lessons counts in both title bar and big button if on the dashboard
        // Update the count in the top right if on the lessons / reviews summary page
        update_pending_counts(counts) {
            let url = new URL(document.URL);
            if (['','/','/dashboard','/dashboard/'].includes(url.pathname)) {
                this.dashboard_update_pending_count(counts.lessons, 'lessons');
                this.dashboard_update_pending_count(counts.reviews, 'reviews');
            } else if (['/review','/review/'].includes(url.pathname)) {
                this.summary_update_pending_count(counts.reviews, 'review');
            } else if (['/lesson','/lesson/'].includes(url.pathname)) {
                this.summary_update_pending_count(counts.lessons, 'lesson');
            }
        }

        // Update the review or lessons count in both title bar and big button for the dashboard
        dashboard_update_pending_count(count, reviews_or_lessons) {
            // update count that shows up in title bar when scrolling
            let reviews_elem = document.getElementsByClassName('navigation-shortcut--' + reviews_or_lessons)[0];
            reviews_elem.setAttribute('data-count', count);
            reviews_elem.getElementsByTagName('span')[0].innerText = count;

            // update count in big button at top of page
            let big_reviews_elem = document.getElementsByClassName('lessons-and-reviews__' + reviews_or_lessons + '-button')[0];
            for (let i=0; i<big_reviews_elem.classList.length; i++) {
                if (big_reviews_elem.classList[i].startsWith(this.threshold_cls_prefix[reviews_or_lessons])) {
                    big_reviews_elem.classList.remove(big_reviews_elem.classList[i]);
                    break;
                }
            }
            let review_threshold = Math.max(
                ...this.thresholds[reviews_or_lessons].filter(threshold => threshold <= count)
            );
            big_reviews_elem.classList.add(this.threshold_cls_prefix[reviews_or_lessons] + review_threshold);
            big_reviews_elem.getElementsByTagName('span')[0].innerText = count;
        }

        // Update the review or lessons count in the top right of the review or lessons summary page
        // The second argument is singular here and plural in dashboard_update_pending_count(...).
        summary_update_pending_count(count, review_or_lesson) {
            let link = document.querySelector('#start-session a');
            let cl = link.classList;
            if (count == 0) {
                link.setAttribute('title', 'No ' + review_or_lesson + 's in queue');
                cl.add('disabled'); // ignores duplicates automatically
                $('#start-session a').on('click', (e) => e.preventDefault()); // add jQuery event handler that prevents click
            } else if (count > 0) {
                link.setAttribute('title', 'Start ' + review_or_lesson + ' session');
                cl.remove('disabled');
                $('#start-session a').off('click'); // remove jQuery event handler that prevents click
            }
            document.getElementById(review_or_lesson + '-queue-count').innerText = count;
        }
    }
})();