[Userscript] Real (Time) Numbers

What is it

Automatically updates the review and lesson counts everywhere on the site. Not sure why WK doesn’t do this on its own. No more refreshing to see if you have new reviews!

Where

Available at: →The Macaron Palace

11 Likes

Thanks for making that script, it’s more attention-grabbing to have the grey 0 turn purple. Hopefully it works in 25 minutes.

Have you thought about making one that places the same thing on the top of the community site as well?

Oh, you know what, I actually forgot about the color. I think I have to change too, and not just update the count. Will add that tomorrow if I can’t find time today.

Have you seen this script for the forums? Or do you mean a script to update this count, too?

1 Like

That’s exactly what I wanted, thank you. Auto-updating that count could also be useful, but if people are browsing the forum, the count is refreshing, whereas the dashboard doesn’t feel like a page you need to reload because it updates the (less visually grabbing) Next Review time.

1 Like

Ok, I finished my reviews so I could tell how WK changes the color, and updated the script to that it should also change the color.

Does this script still work with the new design to automatically refresh the review count at the beginning of each hour?

1 Like

Should do, yes!

It was giving me an error about the WKOF Apiv2 submodule not being found. I added an include statement for it. I also made it update the big button at the top of the page that was added in the redesign.

@@ -11,7 +11,12 @@
 // @grant        none
 // ==/UserScript==

+wkof.include('Apiv2');
+wkof.ready('Apiv2').then(
 (function() {
+    let review_thresholds = [0,1,50,100,250,500,1000]; // thresholds where reviews button image changes, in increasing order
+    let review_threshold_cls_prefix = "lessons-and-reviews__reviews-button--";
+
     // Wait until the top of the hour then update the review count
     wait_until(get_next_hour(), fetch_and_update);

@@ -46,11 +51,24 @@
         return promise;
     }

-    // Update the review count on the dashboard
+    // Update the review count in both places on the dashboard
     function update_review_count(review_count) {
+        // update reviews # that shows up in title bar when scrolling
         var reviews_elem = document.getElementsByClassName('navigation-shortcut--reviews')[0];
         reviews_elem.setAttribute('data-count', review_count);
         reviews_elem.getElementsByTagName('span')[0].innerText = review_count;
+
+        // update reviews number in big button at top of page
+        var big_reviews_elem = document.getElementsByClassName('lessons-and-reviews__reviews-button')[0];
+        for (let i=0; i<big_reviews_elem.classList.length; i++) {
+            if (big_reviews_elem.classList[i].startsWith(review_threshold_cls_prefix)) {
+                big_reviews_elem.classList.remove(big_reviews_elem.classList[i]);
+                break;
+            }
+        }
+        var review_threshold = review_thresholds.filter((threshold) => threshold <= review_count).splice(-1)[0];
+        big_reviews_elem.classList.add(review_threshold_cls_prefix + review_threshold);
+        big_reviews_elem.getElementsByTagName('span')[0].innerText = review_count;
     }

     // Create a new promise and resolve function
@@ -58,4 +76,4 @@
         var resolve, promise = new Promise((res, rej)=>{resolve = res;});
 		return [promise, resolve];
     }
-})();
\ No newline at end of file
+}));
\ No newline at end of file
1 Like

Ah, oops. Must have forgotten about that.

Oh, I thought I had already updated this. Sorry. I will incorporate all of these changes tomorrow and publish an official update

2 Likes

I’ve added some additional features too, if you’re interested in upstreaming any of them:

  • now updates both lessons and reviews
  • updates whenever you switch to the tab or window visibility changes
  • updates whenever you regain a connection to the internet
  • still updates hourly too
  • hourly updates bypass wkof cache to ensure new reviews get added
  • hourly update counts include any reviews that will be available out to 45 seconds into the future in case clock differences caused the script to check for updates too early
  • subtle fade effect when update changes the background

Here is the script with those additional changes. I would have provided a diff, but things moved around too much for the diff to be useful.

// ==UserScript==
// @name         Wanikani: Real (Time) Numbers
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  Updates the review count automatically as soon as new reviews are due
// @author       Kumirei
// @match        https://www.wanikani.com
// @match        https://www.wanikani.com/dashboard
// @match        https://preview.wanikani.com
// @match        https://preview.wanikani.com/dashboard
// @grant        none
// ==/UserScript==

wkof.include('Apiv2');
wkof.ready('Apiv2').then(
(function() {
    // 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);
    }

    // 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() {
        var current_date = new Date();
        return new Date(current_date.toDateString() + ' ' + (current_date.getHours()+1) + ':').getTime();
    }

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


// 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 dashboard
    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() {
        var 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) {
        var pending = [];
        var 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
    update_pending_counts(counts) {
        this.update_pending_count(counts.lessons, 'lessons');
        this.update_pending_count(counts.reviews, 'reviews');
    }

    // Update the review or lessons count in both title bar and big button
    update_pending_count(count, reviews_or_lessons) {
        // update count that shows up in title bar when scrolling
        var 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
        var 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;
            }
        }
        var 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;
    }
}


// function for adding style
// from https://greasyfork.org/en/scripts/35383-gm-addstyle-polyfill/code
GM_addStyle = function (aCss) {
  'use strict';
  let head = document.getElementsByTagName('head')[0];
  if (head) {
    let style = document.createElement('style');
    style.setAttribute('type', 'text/css');
    style.textContent = aCss;
    head.appendChild(style);
    return style;
  }
  return null;
};

// Fade backgrounds on lessons/reviews update
GM_addStyle(`
.lessons-and-reviews__reviews-button, .lessons-and-reviews__lessons-button,
navigation-shortcut--reviews, navigation-shortcut--lessons {
transition: background 300ms;
}
`);
1 Like

Is there any particular reason as to why you put the class and CSS functions in the global scope?

There’s no good reason to make it global. Below is a version which keeps everything local and also makes the script work for the count in the button in the top right corner of the lesson/review summary page.

// ==UserScript==
// @name         Wanikani: Real (Time) Numbers
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  Updates the review count automatically as soon as new reviews are due
// @author       Kumirei
// @match        https://www.wanikani.com
// @match        https://www.wanikani.com/dashboard
// @match        https://www.wanikani.com/review
// @match        https://www.wanikani.com/lesson
// @match        https://preview.wanikani.com
// @match        https://preview.wanikani.com/dashboard
// @match        https://preview.wanikani.com/review
// @match        https://preview.wanikani.com/lesson
// @grant        none
// ==/UserScript==

(function() {
    wkof.include('Apiv2');
    wkof.ready('Apiv2').then(initialize)

    function initialize() {
        // 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);
        }

        // 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() {
            var current_date = new Date();
            return new Date(current_date.toDateString() + ' ' + (current_date.getHours()+1) + ':').getTime();
        }

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

    // 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--"};
            this.session_start_tooltip_with_pending = {review: 'Start review session',
                                                       lesson: "Start lessons"};
        }

        // 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() {
            var 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) {
            var pending = [];
            var 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) {
            var 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
            var 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
            var 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;
                }
            }
            var 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) {
            var link = document.querySelector('#start-session a')
            var cl = link.classList;
            if (count == 0) {
                link.setAttribute('title', 'No ' + review_or_lesson + 's in queue');
                cl.add('disabled'); // ignores duplicates automatically
            } else if (count > 0) {
                link.setAttribute('title', this.session_start_tooltip_with_pending[review_or_lesson]);
                cl.remove('disabled');
            }
            document.getElementById(review_or_lesson + '-queue-count').innerText = count;
        }
    }


    // function for adding style
    // from https://greasyfork.org/en/scripts/35383-gm-addstyle-polyfill/code
    GM_addStyle = function (aCss) {
        'use strict';
        let head = document.getElementsByTagName('head')[0];
        if (head) {
            let style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.textContent = aCss;
            head.appendChild(style);
            return style;
        }
        return null;
    };

// Fade backgrounds on lessons/reviews update for dashboard.
// Summary pages don't need special style rule
// because they already have a similar one.
GM_addStyle(`
.lessons-and-reviews__reviews-button, .lessons-and-reviews__lessons-button,
navigation-shortcut--reviews, navigation-shortcut--lessons {
     transition: background 300ms;
}
`);
})();
1 Like

I already updated the script with your previous additions (in a local scope), but I’ll add the summary page function 230185602321088514

1 Like

Thanks! Below is a diff with a few bug fixes to the 1.2.0 version:

  1. The @match list in the version you uploaded didn’t include the urls for the review / summary pages. Also, it seems that the rules also need to include the quiz session pages or else the script doesn’t load on the summary page if it was navigated to automatically at the end of a quiz session.

  2. WaniKani has a jQuery click event listener which prevented clicking the “start session” button on the summary pages after the count is updated from zero to nonzero.

  3. Calls to fetch_and_update_recurring were failing because the variable tpu was out of scope.

  4. Navigating away from a page and then using the back/forward button caused the page to forget any updated counts. The script now triggers an immediate update if the page was navigated to using the back/forward button.

  5. The title attributes applied were updated to match the ones WaniKani uses.

The diff below fixes all five issues. Thanks again!

@@ -1,17 +1,28 @@
 // ==UserScript==
 // @name         Wanikani: Real (Time) Numbers
 // @namespace    http://tampermonkey.net/
-// @version      1.2.0
+// @version      1.2.1
 // @description  Updates the review count automatically as soon as new reviews are due
 // @author       Kumirei
 // @match        https://www.wanikani.com
 // @match        https://www.wanikani.com/dashboard
+// @match        https://www.wanikani.com/review
+// @match        https://www.wanikani.com/review/*
+// @match        https://www.wanikani.com/lesson
+// @match        https://www.wanikani.com/lesson/*
 // @match        https://preview.wanikani.com
 // @match        https://preview.wanikani.com/dashboard
+// @match        https://preview.wanikani.com/review
+// @match        https://preview.wanikani.com/review/*
+// @match        https://preview.wanikani.com/lesson
+// @match        https://preview.wanikani.com/lesson/*
 // @grant        none
 // ==/UserScript==
 /*jshint esversion: 8 */

+// @match 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
@@ -30,6 +41,12 @@
         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
@@ -51,12 +68,13 @@
             transition: background 300ms;
         }`;
         add_css(css, 'real-time-numbers-css');
-    }

-    // 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 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
@@ -70,6 +88,8 @@
         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>`);
     }
@@ -91,8 +111,6 @@
                                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--"};
-            this.session_start_tooltip_with_pending = {review: 'Start review session',
-                                                       lesson: "Start lessons"};
         }

         // Fetches the review/lessons counts, updates the counts on the page
@@ -164,9 +182,11 @@
             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', this.session_start_tooltip_with_pending[review_or_lesson]);
+                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;
         }
2 Likes

The script is more you than it is me now, haha. Might have been easier for you to just create a new script

Anyway, I have published the fixes. Only change I made was that I consolidated all the @matches into a single @include /^https://(www|preview).wanikani.com/(lesson/*|review/*|dashboard)?/

1 Like