Greasy Fork is available in English.

Wanikani Forum: Regular Tracker

Tracks how regular you are

// ==UserScript==
// @name         Wanikani Forum: Regular Tracker
// @namespace    http://tampermonkey.net/
// @version      1.1.9
// @description  Tracks how regular you are
// @author       Kumirei
// @include      *community.wanikani.com*
// @grant        none
// ==/UserScript==

;(function () {
    // Settings object. Configure your settings here
    let settings = {
        update_interval: 10, // Minutes between fetches of the summary page data
    }

    // Global tracker object. Keeps track of your efforts of meeting the regularity criteria in the last 100 days
    let tracker = {
        last_fetch: 0, // Timestamp of when summary and category data was last fetched
        history: [], // Contains all fetches of the summary and category data going back 100 days
        streak: [0, 0], // Last date visited and the current streak
        visited: [], // List of visited dates [date1, date2, ...]
        unique_topics: 0, // Not interested in going through the trouble of tracking this right now
        flags_received: 0, // No way to detect this as far as I am aware
        suspended: false, // No way to detect this as far as I am aware
        regular: false, // Boolean indicating whether you have regular status
    }

    const msday = 1000 * 60 * 60 * 24 // Number of ms in a day
    init()

    // First run setup
    function init() {
        add_display()
        add_css()
        fetch_data()
        setInterval(fetch_data, (1000 * 60 * settings.update_interval) / 10)
    }

    // Fetches the needed data
    function fetch_data() {
        update_tracker()
        if (tracker.last_fetch < Date.now() - 1000 * 60 * settings.update_interval) {
            tracker.last_fetch = Date.now()
            save()
            let username = document.querySelector('#current-user button').getAttribute('href')?.replace('/u/', '') || ''
            let summary_url = 'https://community.wanikani.com/u/' + username + '/summary'
            let stats_url = 'https://community.wanikani.com/about'
            Promise.all([fetchUrl(summary_url), fetchUrl(stats_url)]).then(process_data)
        }
    }

    async function fetchUrl(url) {
        const data = await fetch(url, {
            headers: {
                accept: 'application/json, text/javascript, */*; q=0.01',
                'x-requested-with': 'XMLHttpRequest',
            },
        })
        const json = await data.json()
        return json
    }

    // Updates the global variable with the new data
    function process_data(data) {
        let [summary_data, about] = data
        let s = summary_data.user_summary
        let stats = about.about.stats
        let history = tracker.history
        if (history.length > 1 && history[history.length - 2].date > Date.now() - 60 * 60 * 1000) history.pop() // Limit to hourly updates
        tracker.history.push({
            date: Date.now(),
            likes_given: s.likes_given,
            likes_received: s.likes_received,
            topics_viewed: s.topics_entered,
            posts_read: s.posts_read_count,
            days_visited: s.days_visited,
            total_topics: stats.topics_count,
            total_posts: stats.posts_count,
            topics_30d: stats.topics_30_days,
            posts_30d: stats.posts_30_days,
        })
        tracker.regular = summary_data.badges[0].id == 3 // Regular has badge ID 3
        const get_date = (date) => new Date(date).toISOString().slice(0, 10)
        tracker.visited.push(get_date(Date.now())) // Append today's date
        tracker.visited.sort()
        tracker.visited = tracker.visited.filter((d, i) => {
            // filter out old visits
            if (!tracker.visited[i + 1]) return true // Keep last
            const date = new Date(d) // Get next day
            date.setDate(date.getDate() + 1)
            return get_date(date.getTime()) == get_date(tracker.visited[i + 1])
        })
        tracker.streak = [null, tracker.visited.length]
        save()
        update_display()
    }

    // Update the tracker variable
    function update_tracker() {
        var stored = JSON.parse(localStorage.getItem('WKFRT'))
        if (stored) {
            for (let key in stored) tracker[key] = stored[key]
        }
    }

    // Stores the data in localStorage so other tabs can access it
    function save() {
        localStorage.setItem('WKFRT', JSON.stringify(tracker))
    }

    // Adds the bubbles to the header
    function add_display() {
        // START code by rfindley
        if (is_dark_theme()) {
            document.body.setAttribute('theme', 'dark')
        } else {
            document.body.setAttribute('theme', 'light')
        }
        var wk_app_nav = document.querySelector('.wanikani-app-nav').closest('.container')
        if (!wk_app_nav) {
            setTimeout(add_display, 200)
            return
        }
        // Attach the Dashboard menu to the stay-on-top menu.
        var top_menu = document.querySelector('.d-header .wrap')
        var main_content = document.querySelector('#main-outlet')
        document.body.classList.add('float_wkappnav')
        wk_app_nav.classList.add('wanikani-app-nav-container')
        top_menu.insertAdjacentElement('beforeend', wk_app_nav)
        // Adjust the main content's top padding, so it won't be hidden under the new taller top menu.
        var main_content_toppad = Number(getComputedStyle(main_content).paddingTop.match(/[0-9]*/)[0])
        main_content.setAttribute('padding-top', main_content_toppad + 25 + 'px')
        // Insert CSS.
        var css =
            '.float_wkappnav .d-header {padding-bottom: 2em;}' +
            '.float_wkappnav .d-header {height: 4em !important;}' +
            '.float_wkappnav .d-header .title {height:4em;}' +
            '.float_wkappnav .wanikani-app-nav-container {border-top:1px solid #ccc; line-height:2em;}' +
            '.float_wkappnav .wanikani-app-nav ul {padding-bottom:0; margin-bottom:0; border-bottom:inherit;}' +
            '.dashboard_bubble {color:#fff; background-color:#bdbdbd; font-size:0.8em; border-radius:0.5em; padding:0 6px; margin:0 0 0 4px; font-weight:bold;}' +
            'li[data-highlight="true"] .dashboard_bubble {background-color:#6cf;}' +
            'body[theme="dark"] .dashboard_bubble {color:#ddd; background-color:#646464;}' +
            'body[theme="dark"] li[data-highlight="true"] .dashboard_bubble {color:#000; background-color:#6cf;}' +
            'body[theme="dark"] .wanikani-app-nav[data-highlight-labels="true"] li[data-highlight="true"] a {color:#6cf;}' +
            'body[theme="dark"] .wanikani-app-nav ul li a {color:#999;}'
        document.head.insertAdjacentHTML('beforeend', '<style type="text/css">' + css + '</style>')
        // END code by rfindley
        if (!document.querySelector('#regular_status')) {
            document
                .querySelector('.wanikani-app-nav ul')
                .insertAdjacentHTML(
                    'beforeend',
                    '<li id="regular_status" data-highlight="false">Regular Status:<span class="dashboard_bubble">0%</span></li>',
                )
            update_display()
        }
    }

    // Function made by rfindley
    function is_dark_theme() {
        // Grab the <html> background color, average the RGB.  If less than 50% bright, it's dark theme.
        return (
            getComputedStyle(document.querySelector('html'))
                .backgroundColor.match(/\((.*)\)/)[1]
                .split(',')
                .slice(0, 3)
                .map((str) => Number(str))
                .reduce((a, i) => a + i) /
                (255 * 3) <
            0.5
        )
    }

    // Updates the display with the current numbers
    function update_display() {
        fetch_data()
        update_tracker()
        prune_history()
        save()
        let elem = document.querySelector('#regular_status')

        if (tracker.history.length != 0 && elem) {
            let days_visited = last100('days_visited') + 1
            let likes_given = last100('likes_given')
            let likes_received = last100('likes_received')
            let topics_viewed = last100('topics_viewed')
            let days_tracked = Math.floor((Date.now() - tracker.history[0].date) / msday)
            let topics_goal = Math.round(last100('total_topics') * 0.25)
            let posts_read = last100('posts_read')
            let post_goal = Math.round(last100('total_posts') * 0.25)
            if (days_tracked < 100) {
                topics_goal = Math.round((topics_goal / days_tracked) * 100) || 0
                post_goal = Math.round((post_goal / days_tracked) * 100) || 0
                if (days_tracked < 30) {
                    topics_goal = Math.round((tracker.history.at(-1).topics_30d / 0.3) * 0.25)
                    post_goal = Math.round((tracker.history.at(-1).posts_30d / 0.3) * 0.25)
                }
            }
            if (topics_goal > 500) topics_goal = 500
            if (post_goal > 20000) post_goal = 20000
            let total_days_visited = tracker.history.at(-1).days_visited
            let visit_streak = tracker.streak[1]
            let title = `
In the last 100 days
–––––––––––––––––––––––––––––
Days Visited:            ${days_visited} / 50 (${Math.round((100 * days_visited) / 50)}%)
Posts Read:                ${posts_read.toLocaleString()} / ${post_goal.toLocaleString()} (${Math.round(
                (100 * posts_read) / post_goal,
            )}%)
Topics Viewed:         ${topics_viewed.toLocaleString()} / ${topics_goal} (${Math.round(
                (100 * topics_viewed) / topics_goal,
            )}%)
Likes Given:              ${likes_given.toLocaleString()} / 30
Likes Received:        ${likes_received.toLocaleString()} / 20
–––––––––––––––––––––––––––––
Topics Posted In:      ??? / 10
Flags Not Received: ??? / 5
Not Suspended:       ???
–––––––––––––––––––––––––––––
Visit Streak:               ${visit_streak}`
            elem.title = title

            let goals = [
                days_visited / 50,
                likes_given / 30,
                likes_received / 20,
                topics_viewed / topics_goal,
                posts_read / post_goal,
            ].reduce((a, b) => {
                return b < a ? b : a
            })
            elem.children[0].innerText = Math.floor(goals * 100) + '%'
            elem.setAttribute('data-highlight', tracker.regular)
        }
    }

    // Deletes any data older than 24 hours
    function prune_history() {
        tracker.history = tracker.history.filter((item) => item.date >= Date.now() - msday * 100)
    }

    // Add CSS which makes space in the header
    function add_css() {
        document.head.insertAdjacentHTML(
            'beforeend',
            `
<style id="WKFRTCSS">
.wanikani-app-nav .wanikani-app-nav-list-header {display: none;}
.wanikani-app-nav > ul {display: flex;}
.wanikani-app-nav #regular_status {order: 4;}
.wanikani-app-nav > ul > li:last-child {margin-right: 1em;}
</style>`,
        )
    }

    function last100(key) {
        const withKey = tracker.history.filter((stats) => key in stats)
        return dict_key_diff(withKey.at(-1), withKey[0], key)
    }

    // Returns the difference between entries in two dicts
    function dict_key_diff(d1, d2, key) {
        return d1[key] - d2[key]
    }

    // Waits until a certain element is created then passes it to the callback function
    async function wait_for_selector(selector, callback, interval = 1000, max_wait = 0, count = 0) {
        let wait_id = Math.random().toString().slice(2)
        let waited = 0
        let found = 0
        let result
        let result_handler = function (e) {
            if (found == count && count != 0) return
            found++
            e.setAttribute('wait_id_' + wait_id, '')
            callback(e)
        }
        while ((waited < max_wait || max_wait == 0) && (found < count || count == 0)) {
            result = document.querySelectorAll(selector + ':not([wait_id_' + wait_id + '])')
            result.forEach(result_handler)
            await sleep(interval)
        }
    }

    // Waits for a specified number of ms before returning
    function sleep(ms) {
        return new Promise((r) => setTimeout(r, ms))
    }
})()