Wanikani Heatmap

Adds review and lesson heatmaps to the dashboard.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Wanikani Heatmap
// @namespace    http://tampermonkey.net/
// @version      3.1.11
// @description  Adds review and lesson heatmaps to the dashboard.
// @author       Kumirei
// @include      /^https://(www|preview).wanikani.com/(dashboard)?$/
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @require      https://greasyfork.org/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1417568
// @require      https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=1193344
// @require      https://greasyfork.org/scripts/410910-heatmap/code/Heatmap.js?version=1251299
// @grant        none
// ==/UserScript==

;(function (wkof, review_cache, Heatmap, Icons) {
    const CSS_COMMIT = '61a780cee6f08eb3a4f8f37068c1e6ce29762e96'
    let script_id = 'heatmap3'
    let script_name = 'Wanikani Heatmap'
    let msh = 60 * 60 * 1000,
        msd = 24 * msh // Milliseconds in hour and day

    Icons.addCustomIcons([
        [
            'trophy',
            'M400 0H176c-26.5 0-48.1 21.8-47.1 48.2c.2 5.3 .4 10.6 .7 15.8H24C10.7 64 0 74.7 0 88c0 92.6 33.5 157 78.5 200.7c44.3 43.1 98.3 64.8 138.1 75.8c23.4 6.5 39.4 26 39.4 45.6c0 20.9-17 37.9-37.9 37.9H192c-17.7 0-32 14.3-32 32s14.3 32 32 32H384c17.7 0 32-14.3 32-32s-14.3-32-32-32H357.9C337 448 320 431 320 410.1c0-19.6 15.9-39.2 39.4-45.6c39.9-11 93.9-32.7 138.2-75.8C542.5 245 576 180.6 576 88c0-13.3-10.7-24-24-24H446.4c.3-5.2 .5-10.4 .7-15.8C448.1 21.8 426.5 0 400 0zM48.9 112h84.4c9.1 90.1 29.2 150.3 51.9 190.6c-24.9-11-50.8-26.5-73.2-48.3c-32-31.1-58-76-63-142.3zM464.1 254.3c-22.4 21.8-48.3 37.3-73.2 48.3c22.7-40.3 42.8-100.5 51.9-190.6h84.4c-5.1 66.3-31.1 111.2-63 142.3z',
            576,
        ],
        [
            'inbox',
            'M121 32C91.6 32 66 52 58.9 80.5L1.9 308.4C.6 313.5 0 318.7 0 323.9V416c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V323.9c0-5.2-.6-10.4-1.9-15.5l-57-227.9C446 52 420.4 32 391 32H121zm0 64H391l48 192H387.8c-12.1 0-23.2 6.8-28.6 17.7l-14.3 28.6c-5.4 10.8-16.5 17.7-28.6 17.7H195.8c-12.1 0-23.2-6.8-28.6-17.7l-14.3-28.6c-5.4-10.8-16.5-17.7-28.6-17.7H73L121 96z',
        ],
    ])

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    var reload // Function to reload the heatmap
    // Temporary measure to track reviews while the /reviews endpoint is unavailable
    function main() {
        if (/www.wanikani.com\/(dashboard)?#?$/.test(window.location.href)) {
            // Wait until modules are ready then initiate script
            confirm_wkof()
            wkof.include('Menu,Settings,ItemData,Apiv2')
            wkof.ready('Menu,Settings,ItemData,Apiv2')
                .then(load_settings)
                .then(load_css)
                .then(install_menu)
                .then(initiate)

            window.addEventListener('turbo:load', async (e) => {
                setTimeout(main, 0)
            })
        }
    }

    main()

    // Fetch necessary data then install the heatmap
    async function initiate() {
        review_cache.subscribe(do_stuff)

        async function do_stuff(reviews) {
            reviews ??= []
            // Fetch data
            let items = await wkof.ItemData.get_items('assignments,include_hidden')
            let [forecast, lessons] = get_forecast_and_lessons(items)
            if (wkof.settings[script_id].lessons.recover_lessons) {
                let recovered_lessons = await get_recovered_lessons(items, reviews, lessons)
                lessons = lessons.concat(recovered_lessons).sort((a, b) => (a[0] < b[0] ? -1 : 1))
            }
            // Create heatmap
            reload = function (new_reviews = false) {
                // If start date is invalid, set it to the default
                if (isNaN(Date.parse(wkof.settings[script_id].general.start_date)))
                    wkof.settings[script_id].general.start_date = '2012-01-01'
                // Get a timestamp for the start date
                wkof.settings[script_id].general.start_day =
                    new Date(wkof.settings[script_id].general.start_date) -
                    -new Date(wkof.settings[script_id].general.start_date).getTimezoneOffset() * 60 * 1000
                setTimeout(() => {
                    // Make settings dialog respond immediately
                    let stats = {
                        reviews: calculate_stats('reviews', reviews),
                        lessons: calculate_stats('lessons', lessons),
                    }
                    auto_range(stats, forecast)
                    install_heatmap(reviews, forecast, lessons, stats, items)
                }, 0)
            }
            reload()
        }
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    function confirm_wkof() {
        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
        }
    }

    // Load settings from WKOF
    function load_settings() {
        let defaults = {
            general: {
                start_date: '2012-01-01',
                week_start: 0,
                day_start: 0,
                reverse_years: false,
                segment_years: true,
                zero_gap: false,
                month_labels: 'all',
                day_labels: true,
                session_limit: 10,
                now_indicator: true,
                color_now_indicator: '#ff0000',
                level_indicator: true,
                color_level_indicator: '#ffffff',
                position: 2,
                theme: 'dark',
            },
            reviews: {
                gradient: false,
                auto_range: true,
            },
            lessons: {
                gradient: false,
                auto_range: true,
                count_zeros: false,
                recover_lessons: false,
            },
            forecast: {
                gradient: false,
                auto_range: true,
            },
            other: {
                visible_years: { reviews: {}, lessons: {} },
                visible_map: 'reviews',
                times_popped: 0,
                times_dragged: 0,
                ported: false,
            },
        }
        return wkof.Settings.load(script_id, defaults).then((settings) => {
            // Workaround for defaults modifying existing settings
            if (!settings.reviews.colors?.length)
                settings.reviews.colors = [
                    [0, '#747474'],
                    [1, '#ade4ff'],
                    [100, '#82c5e6'],
                    [200, '#57a5cc'],
                    [300, '#2b86b3'],
                    [400, '#006699'],
                ]
            if (!settings.lessons.colors?.length)
                settings.lessons.colors = [
                    [0, '#747474'],
                    [1, '#ff8aa1'],
                    [100, '#e46e9e'],
                    [200, '#c8539a'],
                    [300, '#ad3797'],
                    [400, '#911b93'],
                ]
            if (!settings.forecast.colors?.length)
                settings.forecast.colors = [
                    [0, '#747474'],
                    [1, '#aaaaaa'],
                    [100, '#bfbfbf'],
                    [200, '#d5d5d5'],
                    [300, '#eaeaea'],
                    [400, '#ffffff'],
                ]
            // Load settings from old script if possible
            if (!settings.other.ported) port_settings(settings)
            migrate_settings(settings)

            // Make sure current year is visible
            for (let type of ['reviews', 'lessons']) {
                wkof.settings[script_id].other.visible_years[type][new Date().getFullYear()] = true
            }
            wkof.Settings.save(script_id)
            return settings
        })
    }

    // Loads heatmap and jQuery datepicker CSS
    function load_css() {
        // Heatmap CSS
        const heatmapRepo = `//raw.githubusercontent.com/Kumirei/Userscripts/${CSS_COMMIT}/Wanikani/Heatmap`
        wkof.load_css(`${heatmapRepo}/Heatmap/Heatmap.css`, true)
        wkof.load_css(`${heatmapRepo}/heatmap3.css`, true)
    }

    // Installs the settings button in the menu
    function install_menu() {
        let config = {
            name: script_id,
            submenu: 'Settings',
            title: 'Heatmap',
            on_click: open_settings,
        }
        wkof.Menu.insert_script_link(config)
    }

    // Add stuff to the settings dialog before opening
    let applied // Keeps track of whether the settings have been applied
    function modify_settings(dialog) {
        // Make start-date a jQuery datepicker
        //window.jQuery(dialog[0].querySelector('#'+script_id+'_start_date')).datepicker({dateFormat: "yy-mm-dd",changeYear: true,yearRange: "2012:+0"});
        // Add apply button
        applied = false
        let apply = create_elem({
            type: 'button',
            class: 'ui-button ui-corner-all ui-widget',
            child: 'Apply',
            onclick: (e) => {
                applied = true
                reload()
            },
        })
        dialog[0].nextElementSibling
            .getElementsByClassName('ui-dialog-buttonset')[0]
            .insertAdjacentElement('afterbegin', apply)
        // Updates the color labels with new hex values
        let update_label = function (input) {
            if (!input.nextElementSibling)
                input.insertAdjacentElement(
                    'afterend',
                    create_elem({ type: 'div', class: 'color-label', child: input.value }),
                )
            else input.nextElementSibling.innerText = input.value
            if (!Math.round(hex_to_rgb(input.value).reduce((a, b) => a + b / 3, 0) / 255 - 0.15))
                input.nextElementSibling.classList.remove('light-color')
            else input.nextElementSibling.classList.add('light-color')
        }
        // Add color settings
        dialog[0]
            .querySelectorAll('#' + script_id + '_general ~ div .wkof_group > div:nth-of-type(2)')
            .forEach((elem, i) => {
                let type = ['reviews', 'lessons', 'forecast'][i]
                // Update the settings object with data from the settings dialog
                let update_color_settings = (_) => {
                    wkof.settings[script_id][type].colors = []
                    Array.from(elem.nextElementSibling.children[1].children).forEach((child, i) => {
                        wkof.settings[script_id][type].colors.push([
                            child.children[0].children[0].value,
                            child.children[1].children[0].value,
                        ])
                    })
                }
                // Creates a new interval setting
                let create_row = (value, color) => {
                    return create_elem({
                        type: 'div',
                        class: 'row',
                        children: [
                            create_elem({
                                type: 'div',
                                class: 'text',
                                child: create_elem({ type: 'input', input: 'number', value: value }),
                            }),
                            create_elem({
                                type: 'div',
                                class: 'color',
                                child: create_elem({
                                    type: 'input',
                                    input: 'color',
                                    value: color,
                                    callback: (e) => e.addEventListener('change', (_) => update_label(e)),
                                }),
                                callback: (e) => update_label(e.children[0]),
                            }),
                            create_elem({
                                type: 'div',
                                class: 'delete',
                                child: create_elem({
                                    type: 'button',
                                    onclick: (e) => {
                                        e.target.closest('.row').remove()
                                        update_color_settings()
                                    },
                                    child: Icons.customIcon('trash'),
                                }),
                            }),
                        ],
                    })
                }
                // Creates the interface for color settings
                let panel = create_elem({
                    type: 'div',
                    class: 'right',
                    children: [
                        create_elem({
                            type: 'button',
                            class: 'adder',
                            onclick: (e) => {
                                e.target.nextElementSibling.append(create_row(0, '#ffffff'))
                                update_color_settings()
                            },
                            child: 'Add interval',
                        }),
                        create_elem({ type: 'div', class: 'row panel' }),
                    ],
                })
                // Update the settings when they change
                panel.addEventListener('change', update_color_settings)
                // Add the existing settings
                for (let [value, color] of wkof.settings[script_id][type].colors)
                    panel.children[1].append(create_row(value, color))
                // Make sure that reviews and forecast have the same zero-color
                if (i == 0 || i == 2)
                    panel.children[1].children[0].addEventListener('change', (e) => {
                        let input = e.target
                            .closest('#' + script_id + '_tabs')
                            .querySelector(
                                '#' +
                                    script_id +
                                    '_' +
                                    (i == 0 ? 'forecast' : 'reviews') +
                                    ' .panel > .row:first-child .color input',
                            )
                        if (input.value != e.target.value) {
                            input.value = e.target.value
                            input.dispatchEvent(new Event('change'))
                            wkof.settings[script_id][i == 0 ? 'forecast' : 'reviews'].colors[0][1] = e.target.value
                        }
                    })
                // Install
                elem.insertAdjacentElement('afterend', panel)
            })
        // Disable the first interval's bound input so that it can't be changed from 0
        dialog[0]
            .querySelectorAll('#' + script_id + '_general ~ div .panel .row:first-child .text input')
            .forEach((elem) => (elem.disabled = true))
        // Add labels to all color inputs
        dialog[0].querySelectorAll('#' + script_id + '_general input[type="color"]').forEach((input) => {
            input.addEventListener('change', () => update_label(input))
            update_label(input)
        })
        // Add functionality to review inserter
        dialog[0].querySelector('#insert_reviews_button').addEventListener('click', (event) => {
            const date = dialog[0].querySelector('#insert_reviews_date').value
            const count = Number(dialog[0].querySelector('#insert_reviews_count').value)
            const spr = Number(dialog[0].querySelector('#insert_reviews_time').value) || 0 // Seconds per review
            if (!date || !count) return

            const mspr = spr * 1000 // MS per review
            const dayStart = wkof.settings[script_id].general.day_start
            const startHour = Math.floor(dayStart)
            const startMin = Math.floor((dayStart % 1) * 60)
            const time = Date.parse(date + `T${String(startHour).padStart(2, 0)}:${String(startMin).padStart(2, 0)}`)
            const reviews = new Array(count).fill(null).map((_, i) => [time + i * mspr, 1, 1, 0, 0])
            review_cache.insert(reviews)
        })
    }

    // Open the settings dialog
    function open_settings() {
        let config = {
            script_id: script_id,
            title: 'Heatmap',
            on_save: (_) => (applied = true),
            on_close: reload_on_change,
            content: {
                tabs: {
                    type: 'tabset',
                    content: {
                        general: {
                            type: 'page',
                            label: 'General',
                            hover_tip: 'Settings pertaining to the general functions of the script',
                            content: {
                                control: {
                                    type: 'group',
                                    label: 'Control',
                                    content: {
                                        position: {
                                            type: 'dropdown',
                                            label: 'Position',
                                            default: 2,
                                            hover_tip: 'Where on the dashboard to install the heatmap',
                                            content: {
                                                0: 'Top',
                                                1: 'Below forecast',
                                                2: 'Below SRS',
                                                3: 'Below panels',
                                                4: 'Bottom',
                                            },
                                            path: '@general.position',
                                        },
                                        start_date: {
                                            type: 'input',
                                            subtype: 'date',
                                            label: 'Start date',
                                            default: '2012-01-01',
                                            hover_tip: 'All data before this date will be ignored',
                                            path: '@general.start_date',
                                        },
                                        week_start: {
                                            type: 'dropdown',
                                            label: 'First day of the week',
                                            default: 0,
                                            hover_tip: 'Determines which day of the week is at the top of the heatmaps',
                                            content: {
                                                0: 'Monday',
                                                1: 'Tuesday',
                                                2: 'Wednesday',
                                                3: 'Thursday',
                                                4: 'Friday',
                                                5: 'Saturday',
                                                6: 'Sunday',
                                            },
                                            path: '@general.week_start',
                                        },
                                        day_start: {
                                            type: 'number',
                                            label: 'New day starts at',
                                            default: 0,
                                            placeholder: '(hours after midnight)',
                                            hover_tip:
                                                'Offset for those who tend to stay up after midnight. If you want the new day to start at 4 AM, input 4.',
                                            path: '@general.day_start',
                                        },
                                        session_limit: {
                                            type: 'number',
                                            label: 'Session time limit (minutes)',
                                            default: 10,
                                            placeholder: '(minutes)',
                                            hover_tip:
                                                'Max number of minutes between review/lesson items to still count within the same session',
                                            path: '@general.session_limit',
                                        },
                                        theme: {
                                            type: 'dropdown',
                                            label: 'Theme',
                                            default: 'dark',
                                            hover_tip: 'Changes the background color and other things',
                                            content: { light: 'Light', dark: 'Dark', 'breeze-dark': 'Breeze Dark' },
                                            path: '@general.theme',
                                        },
                                    },
                                },
                                layout: {
                                    type: 'group',
                                    label: 'Layout',
                                    content: {
                                        reverse_years: {
                                            type: 'checkbox',
                                            label: 'Reverse year order',
                                            default: false,
                                            hover_tip: 'Puts the most recent years on the bottom instead of the top',
                                            path: '@general.reverse_years',
                                        },
                                        segment_years: {
                                            type: 'checkbox',
                                            label: 'Segment year',
                                            default: true,
                                            hover_tip: 'Put a gap between months',
                                            path: '@general.segment_years',
                                        },
                                        zero_gap: {
                                            type: 'checkbox',
                                            label: 'No gap',
                                            default: false,
                                            hover_tip: `Don't display any gap between days`,
                                            path: '@general.zero_gap',
                                        },
                                        day_labels: {
                                            type: 'dropdown',
                                            label: 'Day of week labels',
                                            default: 'english',
                                            hover_tip:
                                                'Adds letters to the left of the heatmaps indicating which row represents which weekday',
                                            content: { none: 'None', english: 'English', kanji: 'Kanji' },
                                            path: '@general.day_labels',
                                        },
                                        month_labels: {
                                            type: 'dropdown',
                                            label: 'Month labels',
                                            default: 'all',
                                            hover_tip: 'Display month labels above each month',
                                            content: { all: 'All', top: 'Only at the top', none: 'None' },
                                            path: '@general.month_labels',
                                        },
                                    },
                                },
                                indicators: {
                                    type: 'group',
                                    label: 'Indicators',
                                    content: {
                                        now_indicator: {
                                            type: 'checkbox',
                                            label: 'Current day indicator',
                                            default: true,
                                            hover_tip: 'Puts a border around the current day',
                                            path: '@general.now_indicator',
                                        },
                                        level_indicator: {
                                            type: 'checkbox',
                                            label: 'Level-up indicators',
                                            default: true,
                                            hover_tip: 'Puts borders around the days on which you leveled up',
                                            path: '@general.level_indicator',
                                        },
                                        color_now_indicator: {
                                            type: 'color',
                                            label: 'Color for current day',
                                            hover_tip: 'The border around the current day will have this color',
                                            default: '#ff0000',
                                            path: '@general.color_now_indicator',
                                        },
                                        color_level_indicator: {
                                            type: 'color',
                                            label: 'Color for level-ups',
                                            hover_tip: 'The borders around level-ups will have this color',
                                            default: '#ffffff',
                                            path: '@general.color_level_indicator',
                                        },
                                    },
                                },
                            },
                        },
                        reviews: {
                            type: 'page',
                            label: 'Reviews',
                            hover_tip: 'Settings pertaining to the review heatmaps',
                            content: {
                                reviews_settings: {
                                    type: 'group',
                                    label: 'Review Settings',
                                    content: {
                                        reviews_section: { type: 'section', label: 'Intervals' },
                                        reviews_auto_range: {
                                            type: 'checkbox',
                                            label: 'Auto range intervals',
                                            default: true,
                                            hover_tip: 'Automatically decide what the intervals should be',
                                            path: '@reviews.auto_range',
                                        },
                                        reviews_gradient: {
                                            type: 'checkbox',
                                            label: 'Use gradients',
                                            default: false,
                                            hover_tip:
                                                'Interpolate colors based on the exact number of items on that day',
                                            path: '@reviews.gradient',
                                        },
                                        reviews_generate: {
                                            type: 'button',
                                            label: 'Generate colors',
                                            text: 'Generate',
                                            hover_tip: 'Generate new colors from the first and last non-zero interval',
                                            on_click: generate_colors,
                                        },
                                        add_reviews_section: { type: 'section', label: 'Manually Register Reviews' },
                                        reviews_insert: {
                                            type: 'html',
                                            html: `
                                            <div>
                                                <div><label>Date <input id="insert_reviews_date" type="date"/></label></div>
                                                <div><label>Count <input id="insert_reviews_count" type="number" min="0" placeholder="Number of reviews" /></label></div>
                                                <div><label>Seconds Per Review <input id="insert_reviews_time" type="number" min="0" placeholder="seconds" value=10 /></label></div>
                                                <div style="display: flex; justify-content: flex-end;"><button id="insert_reviews_button">Register</button></div>
                                            </div>
                                            `,
                                        },
                                        // reviews_section2: { type: 'section', label: 'Other' },
                                        // reload_button: {
                                        //     type: 'button',
                                        //     label: 'Reload review data',
                                        //     text: 'Reload',
                                        //     hover_tip: 'Deletes review cache and starts a new fetch',
                                        //     on_click: () => review_cache.reload().then((reviews) => reload(reviews)),
                                        // },
                                    },
                                },
                            },
                        },
                        lessons: {
                            type: 'page',
                            label: 'Lessons',
                            hover_tip: 'Settings pertaining to the lesson heatmaps',
                            content: {
                                lessons_settings: {
                                    type: 'group',
                                    label: 'Lesson Settings',
                                    content: {
                                        lessons_section: { type: 'section', label: 'Intervals' },
                                        lessons_auto_range: {
                                            type: 'checkbox',
                                            label: 'Auto range intervals',
                                            default: true,
                                            hover_tip: 'Automatically decide what the intervals should be',
                                            path: '@lessons.auto_range',
                                        },
                                        lessons_gradient: {
                                            type: 'checkbox',
                                            label: 'Use gradients',
                                            default: false,
                                            hover_tip:
                                                'Interpolate colors based on the exact number of items on that day',
                                            path: '@lessons.gradient',
                                        },
                                        lessons_generate: {
                                            type: 'button',
                                            label: 'Generate colors',
                                            text: 'Generate',
                                            hover_tip: 'Generate new colors from the first and last non-zero interval',
                                            on_click: generate_colors,
                                        },
                                        lessons_section2: { type: 'section', label: 'Other' },
                                        lessons_count_zeros: {
                                            type: 'checkbox',
                                            label: 'Include zeros in streak',
                                            default: false,
                                            hover_tip: 'Counts days with no lessons available towards the streak',
                                            path: '@lessons.count_zeros',
                                        },
                                        recover_lessons: {
                                            type: 'checkbox',
                                            label: 'Recover reset lessons',
                                            default: false,
                                            hover_tip:
                                                'Allow the Heatmap to guess when you did lessons for items that have been reset',
                                            path: '@lessons.recover_lessons',
                                        },
                                    },
                                },
                            },
                        },
                        forecast: {
                            type: 'page',
                            label: 'Review Forecast',
                            hover_tip: 'Settings pertaining to the forecast',
                            content: {
                                forecast_settings: {
                                    type: 'group',
                                    label: 'Forecast Settings',
                                    content: {
                                        forecast_section: { type: 'section', label: 'Intervals' },
                                        forecast_auto_range: {
                                            type: 'checkbox',
                                            label: 'Auto range intervals',
                                            default: true,
                                            hover_tip: 'Automatically decide what the intervals should be',
                                            path: '@forecast.auto_range',
                                        },
                                        forecast_gradient: {
                                            type: 'checkbox',
                                            label: 'Use gradients',
                                            default: false,
                                            hover_tip:
                                                'Interpolate colors based on the exact number of items on that day',
                                            path: '@forecast.gradient',
                                        },
                                        forecast_generate: {
                                            type: 'button',
                                            label: 'Generate colors',
                                            text: 'Generate',
                                            hover_tip: 'Generate new colors from the first and last non-zero interval',
                                            on_click: generate_colors,
                                        },
                                    },
                                },
                            },
                        },
                    },
                },
            },
        }
        let dialog = new wkof.Settings(config)
        config.pre_open = (elem) => {
            dialog.refresh()
            modify_settings(elem)
        } // Refresh to populate settings before modifying
        delete wkof.settings[script_id].wkofs_active_tabs // Make settings dialog always open in first tab because it is so much taller
        dialog.open()
    }

    // Fetches user's v2 settings if they exist
    async function port_settings(settings) {
        if (wkof.file_cache.dir['wkof.settings.wanikani_heatmap']) {
            let old = await wkof.file_cache.load('wkof.settings.wanikani_heatmap')
            settings.general.start_date = old.general.start_date
            settings.general.week_start = old.general.week_start ? 0 : 6
            settings.general.day_start = old.general.hours_offset
            settings.general.reverse_years = old.general.reverse_years
            settings.general.segment_years = old.general.segment_years
            settings.general.day_labels = old.general.day_labels
            settings.general.now_indicator = old.general.today
            settings.general.color_now_indicator = old.general.today_color
            settings.general.level_indicator = old.general.level_ups
            settings.general.color_level_indicator = old.general.level_ups_color
            settings.reviews.auto_range = old.reviews.auto_range
            settings.reviews.colors = [
                [0, '#747474'],
                [1, old.reviews.color1],
                [old.reviews.interval1, old.reviews.color2],
                [old.reviews.interval2, old.reviews.color3],
                [old.reviews.interval3, old.reviews.color4],
                [old.reviews.interval4, old.reviews.color5],
            ]
            settings.forecast.colors = [
                [0, '#747474'],
                [1, old.reviews.forecast_color1],
                [old.reviews.interval1, old.reviews.forecast_color2],
                [old.reviews.interval2, old.reviews.forecast_color3],
                [old.reviews.interval3, old.reviews.forecast_color4],
                [old.reviews.interval4, old.reviews.forecast_color5],
            ]
            settings.forecast.auto_range = old.reviews.auto_range
            settings.lessons.colors = [
                [0, '#747474'],
                [1, old.lessons.color1],
                [old.lessons.interval1, old.lessons.color2],
                [old.lessons.interval2, old.lessons.color3],
                [old.lessons.interval3, old.lessons.color4],
                [old.lessons.interval4, old.lessons.color5],
            ]
            settings.lessons.auto_range = old.lessons.auto_range
            settings.lessons.count_zeros = old.lessons.count_zeros
        }
        settings.other.ported = true
    }

    // Updates settings if someone has outdated settings
    function migrate_settings(settings) {
        // Changed day labels from checkbox to dropdown
        if (typeof settings.general.day_labels === 'boolean')
            settings.general.day_labels = settings.general.day_labels ? 'english' : 'none'
    }

    // Reload the heatmap if settings have been changed
    function reload_on_change(settings) {
        if (applied) reload()
    }

    // Generates new colors for the intervals in the settings dialog
    function generate_colors(setting_name) {
        // Find the intervals
        let type = setting_name.split('_')[0]
        let panel = document.getElementById(script_id + '_' + type + '_settings').querySelector('.panel')
        let colors = wkof.settings[script_id][type].colors
        // Interpolate between first and last non-zero interval
        let first = colors[1]
        let last = colors[colors.length - 1]
        for (let i = 2; i < colors.length; i++) {
            colors[i][1] = interpolate_color(first[1], last[1], (i - 1) / (colors.length - 2))
        }
        // Refresh settings
        panel.querySelectorAll('.color input').forEach((input, i) => {
            input.value = colors[i][1]
            input.dispatchEvent(new Event('change'))
        })
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    // Extract upcoming reviews and completed lessons from the WKOF cache
    function get_forecast_and_lessons(data) {
        let forecast = [],
            lessons = []
        let time_now = Date.now()
        let vacation_offset = time_now - new Date(wkof.user.current_vacation_started_at || time_now)
        for (let item of data) {
            if (item.assignments?.started_at && item.assignments.unlocked_at) {
                // If the assignment has been started add a lesson containing staring date, id, level, and unlock date
                lessons.push([
                    Date.parse(item.assignments.started_at),
                    item.id,
                    item.data.level,
                    Date.parse(item.assignments.unlocked_at),
                ])
                // If item is in the future and it is not hidden by Wanikani, add the item to the forecast array
                if (
                    item.assignments.available_at &&
                    Date.parse(item.assignments.available_at) > time_now &&
                    item.data.hidden_at === null
                ) {
                    // If the assignment is scheduled add a forecast item ready for sending to the heatmap module
                    let forecast_item = [
                        Date.parse(item.assignments.available_at) + vacation_offset,
                        { forecast: 1 },
                        { 'forecast-ids': item.id },
                    ]
                    forecast_item[1]['forecast-srs1-' + item.assignments.srs_stage] = 1
                    forecast.push(forecast_item)
                }
            }
        }
        // Sort lessons by started_at for easy extraction of chronological info
        lessons.sort((a, b) => (a[0] < b[0] ? -1 : 1))
        return [forecast, lessons]
    }

    // Fetch recovered lessons from storage or recover lessons then return them
    async function get_recovered_lessons(items, reviews, real_lessons) {
        if (!wkof.file_cache.dir.recovered_lessons) {
            let recovered_lessons = await recover_lessons(items, reviews, real_lessons)
            wkof.file_cache.save('recovered_lessons', recovered_lessons)
            return recovered_lessons
        } else return await wkof.file_cache.load('recovered_lessons')
    }

    // Use review data to guess when the lesson was done for all reset items
    async function recover_lessons(items, reviews, real_lessons) {
        // Fetch and prepare data
        let resets = await wkof.Apiv2.get_endpoint('resets')
        let items_id = wkof.ItemData.get_index(items, 'subject_id')
        let delay = 4 * msh
        let app1_reviews = reviews
            .filter((a) => a[2] == 1)
            .map((item) => [item[0] - delay, item[1], items_id[item[1]].data.level, item[0] - delay])
        // Check reviews based on reset intervals
        let last_date = 0,
            recovered_lessons = []
        Object.values(resets)
            .sort((a, b) => (a.data.confirmed_at < b.data.confirmed_at ? -1 : 1))
            .forEach((reset) => {
                let ids = {},
                    date = Date.parse(reset.data.confirmed_at)
                // Filter out items not belonging to the current reset period
                let reset_reviews = app1_reviews.filter((a) => a[0] > last_date && a[0] < date)
                // Choose the earliest App1 review
                reset_reviews.forEach((item) => {
                    if (!ids[item[1]] || ids[item[1]][0] > item[0]) ids[item[1]] = item
                })
                // Remove items that still have lesson data
                real_lessons.filter((a) => a[0] < date).forEach((item) => delete ids[item[1]])
                // Save recovered lessons to array
                Object.values(ids).forEach((item) => recovered_lessons.push(item))
                last_date = date
            })
        return recovered_lessons
    }

    // Calculate overall stats for lessons and reviews
    function calculate_stats(type, data) {
        let settings = wkof.settings[script_id]
        let streaks = get_streaks(type, data)
        let longest_streak = Math.max(...Object.values(streaks))
        let current_streak = streaks[new Date(Date.now() - msh * settings.general.day_start).toDateString()]
        let stats = {
            total: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
            days_studied: [0, 0], // [days studied, percentage]
            average: [0, 0, 0], // [average, per studied, standard deviation]
            streak: [longest_streak, current_streak], // [longest streak, current streak]
            sessions: 0, // Number of sessions
            time: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
            days: 0, // Number of days since first review
            max_done: [0, 0], // Max done in one day [count, date]
            streaks, // Streaks object
        }
        let last_day = new Date(0) // Last item's date
        let today = new Date() // Today
        let d = new Date(Date.now() - msd) // 24 hours ago
        let week = new Date(Date.now() - 7 * msd) // 7 days ago
        let month = new Date(Date.now() - 30 * msd) // 30 days ago
        let year = new Date(Date.now() - 365 * msd) // 365 days ago
        let last_time = 0 // Last item's timestamp
        let done_day = 0 // Total done on the date of the item
        let done_days = [] // List of total done on each day
        let start_date = new Date(settings.general.start_day) // User's start date
        for (let item of data) {
            let day = new Date(item[0] - msh * settings.general.day_start)
            if (day < start_date) continue // If item is before start, discard it
            // If it's a new day
            if (last_day.toDateString() != day.toDateString()) {
                stats.days_studied[0]++
                done_days.push(done_day)
                done_day = 0
            }
            // Update done this day
            done_day++
            if (done_day > stats.max_done[0]) stats.max_done = [done_day, day.toDateString().replace(/... /, '')]
            let minutes = (item[0] - last_time) / 60000
            // Update sessions
            if (minutes > settings.general.session_limit) {
                stats.sessions++
                minutes = 0
            }
            // Update totals
            stats.total[0]++
            stats.time[0] += minutes
            // Done in the last year
            if (year < day) {
                stats.total[1]++
                stats.time[1] += minutes
            }
            // Done in the last month
            if (month < day) {
                stats.total[2]++
                stats.time[2] += minutes
            }
            // Done in the last week
            if (week < day) {
                stats.total[3]++
                stats.time[3] += minutes
            }
            // Done in the last 24 hours
            if (d < day) {
                stats.total[4]++
                stats.time[4] += minutes
            }
            // Done today
            if (today.toDateString() == day.toDateString()) {
                stats.total[5]++
                stats.time[5] += minutes
            }
            // Store values for next item
            last_day = day
            last_time = item[0]
        }
        // Update averages
        done_days.push(done_day)
        const day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
        const first_date = data?.[0]?.[0] || day_start_adjust
        stats.days =
            Math.round(
                (Date.parse(new Date().toDateString()) -
                    Math.max(
                        Date.parse(new Date(first_date).toDateString()),
                        new Date(settings.general.start_day).getTime(),
                    )) /
                    msd,
            ) + 1
        stats.days_studied[1] = Math.round((stats.days_studied[0] / stats.days) * 100)
        stats.average[0] = Math.round(stats.total[0] / stats.days)
        stats.average[1] = Math.round(stats.total[0] / stats.days_studied[0])
        stats.average[2] = Math.sqrt(
            (1 / stats.days_studied[0]) *
                done_days.map((x) => Math.pow(x - stats.average[1], 2)).reduce((a, b) => a + b, 0),
        )
        return stats
    }

    // Finds streaks
    function get_streaks(type, data) {
        let settings = wkof.settings[script_id]
        let day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
        // Initiate dates
        let streaks = {},
            zeros = {}
        const first_date = data?.[0]?.[0] || day_start_adjust
        for (
            let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
            day <= new Date();
            day.setDate(day.getDate() + 1)
        ) {
            streaks[day.toDateString()] = 0
            zeros[day.toDateString()] = true
        }
        // For all dates where something was done, set streak to 1
        for (let [date] of data)
            if (new Date(date) > new Date(settings.general.start_day))
                streaks[new Date(date - day_start_adjust).toDateString()] = 1
        // If user wants to count days where no lessons were available, set those streaks to 1 as well
        if (type === 'lessons' && settings.lessons.count_zeros) {
            // Delete dates where lessons were available
            for (let [started_at, id, level, unlocked_at] of data) {
                for (
                    let day = new Date(unlocked_at - day_start_adjust);
                    day <= new Date(started_at - day_start_adjust);
                    day.setDate(day.getDate() + 1)
                ) {
                    delete zeros[day.toDateString()]
                }
            }
            // Set all remaining dates to streak 1
            for (let date of Object.keys(zeros)) streaks[date] = 1
        }
        // Cumulate streaks
        let streak = 0
        for (
            let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
            day <= new Date().setHours(24);
            day.setDate(day.getDate() + 1)
        ) {
            if (streaks[day.toDateString()] === 1) streak++
            else streak = 0
            streaks[day.toDateString()] = streak
        }
        if (streaks[new Date().toDateString()] == 0)
            streaks[new Date().toDateString()] = streaks[new Date(new Date().setHours(-12)).toDateString()] || 0
        return streaks
    }

    // Get level up dates from API and lesson history
    async function get_level_ups(items) {
        let level_progressions = await wkof.Apiv2.get_endpoint('level_progressions')
        let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].data.unlocked_at
        // Find indefinite level ups by looking at lesson history
        let levels = {}
        // Sort lessons by level then unlocked date
        items.forEach((item) => {
            if (
                item.object !== 'kanji' ||
                !item.assignments ||
                !item.assignments.unlocked_at ||
                item.assignments.unlocked_at >= first_recorded_date
            )
                return
            let date = new Date(item.assignments.unlocked_at).toDateString()
            if (!levels[item.data.level]) levels[item.data.level] = {}
            if (!levels[item.data.level][date]) levels[item.data.level][date] = 1
            else levels[item.data.level][date]++
        })
        // Discard dates with less than 10 unlocked
        // then discard levels with no dates
        // then keep earliest date for each level
        for (let [level, data] of Object.entries(levels)) {
            for (let [date, count] of Object.entries(data)) {
                if (count < 10) delete data[date]
            }
            if (Object.keys(levels[level]).length == 0) {
                delete levels[level]
                continue
            }
            levels[level] = Object.keys(data).reduce((low, curr) => (low < curr ? low : curr), Date.now())
        }
        // Map to array of [[level0, date0], [level1, date1], ...] Format
        levels = Object.entries(levels).map(([level, date]) => [Number(level), date])
        // Add definite level ups from API
        Object.values(level_progressions).forEach((level) =>
            levels.push([level.data.level, new Date(level.data.unlocked_at).toDateString()]),
        )
        return levels
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    // Create and install the heatmap
    async function install_heatmap(reviews, forecast, lessons, stats, items) {
        let settings = wkof.settings[script_id]
        // Create elements
        let heatmap =
            document.getElementById('heatmap') ||
            create_elem({
                type: 'section',
                id: 'heatmap',
                class: 'heatmap ' + (settings.other.visible_map === 'reviews' ? 'reviews' : ''),
                position: settings.general.position,
                onclick: day_click({ reviews, forecast, lessons }),
            })
        let buttons = create_buttons()
        let views = create_elem({ type: 'div', class: 'views' })
        heatmap.onmousedown = heatmap.onmouseup = heatmap.onmouseover = click_and_drag({ reviews, forecast, lessons })
        heatmap.setAttribute('theme', settings.general.theme)
        heatmap.style.setProperty(
            '--color-now',
            settings.general.now_indicator ? settings.general.color_now_indicator : 'transparent',
        )
        heatmap.style.setProperty(
            '--color-level',
            settings.general.level_indicator ? settings.general.color_level_indicator : 'transparent',
        )
        // Create heatmaps
        let cooked_reviews = cook_data('reviews', reviews)
        let cooked_lessons = cook_data('lessons', lessons)
        let level_ups = await get_level_ups(items)
        let reviews_view = create_view(
            'reviews',
            stats,
            level_ups,
            reviews?.[0]?.[0] || Date.now(),
            forecast.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
            cooked_reviews.concat(forecast),
        )
        let lessons_view = create_view(
            'lessons',
            stats,
            level_ups,
            lessons?.[0]?.[0] || Date.now(),
            lessons.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
            cooked_lessons,
        )
        let popper = create_popper({ reviews: cooked_reviews, forecast, lessons: cooked_lessons })
        views.append(reviews_view, lessons_view, popper)
        // Install
        heatmap.innerHTML = ''
        heatmap.append(buttons, views)
        let position = [
            ['.dashboard__content', 'beforebegin'],
            ['.dashboard__srs-progress', 'afterbegin'],
            ['.srs-progress', 'afterend'],
            ['.dashboard__item-lists', 'beforeend'],
            ['.dashboard__content', 'afterend'],
        ][settings.general.position]
        if (!document.getElementById('heatmap') || heatmap.getAttribute('position') != settings.general.position)
            document.querySelector(position[0]).insertAdjacentElement(position[1], heatmap)
        heatmap.setAttribute('position', settings.general.position)
        // Fire event to let people know it's finished loading
        fire_event('heatmap-loaded', heatmap)
    }

    // Creates the buttons at the top of the heatmap
    function create_buttons() {
        let buttons = create_elem({ type: 'div', class: 'buttons' })
        add_transitions(buttons)
        const leftButtons = create_elem({ type: 'div', class: 'left' })
        let settings_button = create_elem({
            type: 'button',
            class: 'settings-button hover-wrapper-target button',
            'aria-label': 'Settings',
            children: [
                create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Settings' }),
                Icons.customIcon('settings'),
            ],
            onclick: open_settings,
        })
        let helpButton = create_elem({
            type: 'a',
            class: 'help-button hover-wrapper-target button',
            'aria-label': 'Settings',
            href: 'https://community.wanikani.com/t/userscript-wanikani-heatmap',
            target: '_blank',
            children: [
                create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Help' }),
                Icons.customIcon('circle-question'),
            ],
        })
        let infoButton = create_elem({
            type: 'a',
            class: 'info-button hover-wrapper-target button',
            'aria-label': 'Settings',
            href: 'https://community.wanikani.com/t/api-changes-get-all-reviews/61617',
            target: '_blank',
            children: [
                create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Why you might be missing reviews' }),
                Icons.customIcon('warning'),
            ],
        })
        leftButtons.append(settings_button, helpButton, infoButton)

        let toggle_button = create_elem({
            type: 'button',
            class: 'toggle-button hover-wrapper-target button',
            'aria-label': 'Toggle between reviews and lessons',
            children: [
                create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Toggle view' }),
                Icons.customIcon('inbox'),
            ],
            onclick: toggle_visible_map,
        })
        buttons.append(leftButtons, toggle_button)
        return buttons
    }

    // Prepares data for the heatmap
    function cook_data(type, data) {
        if (type === 'reviews') {
            let ans = (srs, err) => {
                let srs2 = srs - Math.ceil(err / 2) * (srs < 5 ? 1 : 2) + (err == 0 ? 1 : 0)
                return srs2 < 1 ? 1 : srs2
            }
            return data.map((item) => {
                let cooked = [
                    item[0],
                    { reviews: 1, pass: item[3] + item[4] == 0 ? 1 : 0, incorrect: item[3] + item[4], streak: item[5] },
                    { 'reviews-ids': item[1] },
                ]
                cooked[1][type + '-srs1-' + item[2]] = 1
                cooked[1][type + '-srs2-' + ans(item[2], item[3] + item[4])] = 1
                return cooked
            })
        } else if (type === 'lessons')
            return data.map((item) => [item[0], { lessons: 1, streak: item[4] }, { 'lessons-ids': item[1] }])
        else if (type === 'forecast') return data
    }

    // Create heatmaps and peripherals such as stats
    function create_view(type, stats, level_ups, first_date, last_date, data) {
        let settings = wkof.settings[script_id]
        let level_marks = level_ups.map(([level, date]) => [date, 'level-up' + (level == 60 ? ' level-60' : '')])
        // New heatmap instance
        let heatmap = new Heatmap(
            {
                type: 'year',
                id: type,
                week_start: settings.general.week_start,
                day_start: settings.general.day_start,
                first_date:
                    Math.max(new Date(settings.general.start_day).getTime(), first_date) -
                    settings.general.day_start * msh,
                last_date: last_date,
                segment_years: settings.general.segment_years,
                zero_gap: settings.general.zero_gap,
                markings: [[new Date(Date.now() - msh * settings.general.day_start), 'today'], ...level_marks],
                day_labels: settings.general.day_labels === 'kanji' && ['月', '火', '水', '木', '金', '土', '日'],
                day_hover_callback: (date, day_data) => {
                    let type2 = type
                    let time = new Date(date[0], date[1] - 1, date[2], 0, 0).getTime()
                    if (
                        type2 === 'reviews' &&
                        time > Date.now() - msh * settings.general.day_start &&
                        day_data.counts.forecast
                    )
                        type2 = 'forecast'
                    let string = `${(day_data.counts[type2] || 0).toLocaleString()} ${
                        type2 === 'forecast'
                            ? 'reviews upcoming'
                            : day_data.counts[type2] === 1
                            ? type2.slice(0, -1)
                            : type2
                    } on ${
                        new Date(time).toDateString().replace(/... /, '') + ' ' + kanji_day(new Date(time).getDay())
                    }`
                    if (time >= new Date(settings.general.start_day).getTime() && time > first_date) {
                        string += `\nDay ${(
                            Math.round(
                                (time -
                                    Date.parse(
                                        new Date(
                                            Math.max(data[0]?.[0] || 0, new Date(settings.general.start_day).getTime()),
                                        ).toDateString(),
                                    )) /
                                    msd,
                            ) + 1
                        ).toLocaleString()}`
                    }
                    if (
                        time < Date.now() &&
                        time >= new Date(settings.general.start_day).getTime() &&
                        time > first_date
                    )
                        string += `, Streak ${stats[type].streaks[new Date(time).toDateString()] || 0}`
                    string += '\n'
                    if (
                        type2 === 'reviews' &&
                        day_data.counts.forecast &&
                        new Date(time).toDateString() == new Date().toDateString()
                    ) {
                        string += `\n${day_data.counts.forecast} more reviews upcoming`
                    }
                    if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
                        string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
                    let level = (level_ups.find((a) => a[1] == new Date(time).toDateString()) || [undefined])[0]
                    if (level) string += '\nYou reached level ' + level + '!'
                    if (wkof.settings[script_id].other.times_popped < 5 && Object.keys(day_data.counts).length !== 0)
                        string += '\nClick for details!'
                    if (
                        wkof.settings[script_id].other.times_popped >= 5 &&
                        wkof.settings[script_id].other.times_dragged < 3 &&
                        Object.keys(day_data.counts).length !== 0
                    )
                        string += '\nDid you know that you can click and drag, too?'
                    return [string]
                },
                color_callback: (date, day_data) => color_picker(type, date, day_data),
            },
            data,
        )
        modify_heatmap(type, heatmap)
        // Create layout
        let view = create_elem({ type: 'div', class: type + ' view' })
        let title = create_elem({ type: 'div', class: 'title', child: type.toProper() })
        let [head_stats, foot_stats] = create_stats_elements(type, stats[type])
        let years = create_elem({ type: 'div', class: 'years' + (settings.general.reverse_years ? ' reverse' : '') })
        if (Math.max(...Object.keys(heatmap.maps)) > new Date().getFullYear()) {
            if (settings.other.visible_years[type][new Date().getFullYear() + 1] !== false)
                years.classList.add('visible-future')
            years.classList.add('has-future')
        }
        years.setAttribute('month-labels', settings.general.month_labels)
        years.setAttribute('day-labels', settings.general.day_labels)
        for (let year of Object.values(heatmap.maps).reverse()) years.prepend(year)
        view.append(title, head_stats, years, foot_stats)
        return view
    }

    // Make changes to the heatmap object before it is displayed
    function modify_heatmap(type, heatmap) {
        for (let [year, map] of Object.entries(heatmap.maps)) {
            let target = map.querySelector('.year-labels')
            let up = create_elem({
                type: 'div',
                class: 'toggle-year up hover-wrapper-target',
                onclick: toggle_year,
                children: [
                    create_elem({
                        type: 'div',
                        class: 'hover-wrapper above',
                        child: create_elem({
                            type: 'div',
                            child:
                                'Click to ' + (year == new Date().getFullYear() ? 'show next' : 'hide this') + ' year',
                        }),
                    }),
                    Icons.customIcon('chevron-up'),
                ],
            })
            let down = create_elem({
                type: 'div',
                class: 'toggle-year down hover-wrapper-target',
                onclick: toggle_year,
                children: [
                    create_elem({
                        type: 'div',
                        class: 'hover-wrapper below',
                        child: create_elem({
                            type: 'div',
                            child:
                                'Click to ' +
                                (year <= new Date().getFullYear() ? 'show previous' : 'hide this') +
                                ' year',
                        }),
                    }),
                    Icons.customIcon('chevron-down'),
                ],
            })
            target.append(up, down)
            if (wkof.settings[script_id].other.visible_years[type][year] === false) map.classList.add('hidden')
        }
    }

    // Create the header and footer stats for a view
    function create_stats_elements(type, stats) {
        // Create an single stat element complete with hover info
        let create_stat_element = (label, value, hover) => {
            return create_elem({
                type: 'div',
                class: 'stat hover-wrapper-target',
                children: [
                    create_elem({ type: 'div', class: 'hover-wrapper above', child: hover }),
                    create_elem({ type: 'span', class: 'stat-label', child: label }),
                    create_elem({ type: 'span', class: 'value', child: value }),
                ],
            })
        }
        // Create the elements
        let head_stats = create_elem({
            type: 'div',
            class: 'head-stats stats',
            children: [
                create_stat_element(
                    'Days Studied',
                    stats.days_studied[1] + '%',
                    stats.days_studied[0].toLocaleString() + ' out of ' + stats.days.toLocaleString(),
                ),
                create_stat_element(
                    'Done Daily',
                    stats.average[0] + ' / ' + (stats.average[1] || 0),
                    'Per Day / Days studied\nMax: ' + stats.max_done[0].toLocaleString() + ' on ' + stats.max_done[1],
                ),
                create_stat_element('Streak', stats.streak[1] + ' / ' + stats.streak[0], 'Current / Longest'),
            ],
        })
        let foot_stats = create_elem({
            type: 'div',
            class: 'foot-stats stats',
            children: [
                create_stat_element(
                    'Sessions',
                    stats.sessions.toLocaleString(),
                    (Math.floor(stats.total[0] / stats.sessions) || 0) + ' per session',
                ),
                create_stat_element(
                    type.toProper(),
                    stats.total[0].toLocaleString(),
                    create_table('left', [
                        ['Year', stats.total[1].toLocaleString()],
                        ['Month', stats.total[2].toLocaleString()],
                        ['Week', stats.total[3].toLocaleString()],
                        ['24h', stats.total[4].toLocaleString()],
                    ]),
                ),
                create_stat_element(
                    'Time',
                    m_to_hm(stats.time[0]),
                    create_table('left', [
                        ['Year', m_to_hm(stats.time[1])],
                        ['Month', m_to_hm(stats.time[2])],
                        ['Week', m_to_hm(stats.time[3])],
                        ['24h', m_to_hm(stats.time[4])],
                    ]),
                ),
            ],
        })
        add_transitions(head_stats)
        add_transitions(foot_stats)
        return [head_stats, foot_stats]
    }

    // Add hover transition
    function add_transitions(elem) {
        elem.addEventListener('mouseover', (event) => {
            const elem = event.target.closest('.hover-wrapper-target')
            if (!elem) return
            elem.classList.add('heatmap-transition')
            setTimeout((_) => elem.classList.remove('heatmap-transition'), 20)
        })
    }

    // Initiates the popper element
    function create_popper(data) {
        // Create layout
        let popper = create_elem({ type: 'div', id: 'popper' })
        let header = create_elem({ type: 'div', class: 'header' })
        let minimap = create_elem({
            type: 'div',
            class: 'minimap',
            children: [
                create_elem({ type: 'span', class: 'minimap-label', child: 'Hours minimap' }),
                create_elem({ type: 'div', class: 'hours-map' }),
            ],
        })
        let stats = create_elem({ type: 'div', class: 'stats' })
        let items = create_elem({ type: 'div', class: 'items' })
        popper.append(header, minimap, stats, items)
        document.addEventListener('click', (event) => {
            if (!event.composedPath().find((a) => a === popper || (a.classList && a.classList.contains('years'))))
                popper.classList.remove('popped')
        })
        // Create header
        header.append(
            create_elem({
                type: 'div',
                class: 'clear hover-wrapper-target',
                children: [
                    create_elem({
                        type: 'div',
                        class: 'hover-wrapper above',
                        child: 'Clear all reviews from this day',
                    }),
                    create_elem({ type: 'button', id: 'clear_reviews', child: Icons.customIcon('trash') }),
                ],
            }),
            create_elem({ type: 'div', class: 'date' }),
            create_elem({
                type: 'div',
                class: 'subheader',
                children: [create_elem({ type: 'span', class: 'count' }), create_elem({ type: 'span', class: 'time' })],
            }),
            create_elem({
                type: 'div',
                class: 'score hover-wrapper-target',
                children: [
                    create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Net progress of SRS levels' }),
                    create_elem({ type: 'span' }),
                ],
            }),
        )
        header.querySelector('#clear_reviews').addEventListener('click', async () => {
            let [start, end] = header
                .querySelector('.date')
                .textContent.split('-')
                .map((d) => new Date(d.replace(/\s*.\s*$/, '')))
            if (!end) end = start
            end = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).getTime() // Include end of interval

            const reviews = await review_cache.get_reviews()
            const newReviews = reviews.filter((review) => review[0] < start || review[0] >= end) // Omit reviews
            await review_cache.reload() // Since API returns empty array this clears cache
            await review_cache.insert(newReviews)
        })
        // Create minimap and stats
        stats.append(
            create_table(
                'left',
                [['Levels'], [' 1-10', 0], ['11-20', 0], ['21-30', 0], ['31-40', 0], ['41-50', 0], ['51-60', 0]],
                { class: 'levels' },
                true,
            ),
            create_table(
                'left',
                [
                    ['SRS'],
                    ['Before / After'],
                    ['App', 0, 0],
                    ['Gur', 0, 0],
                    ['Mas', 0, 0],
                    ['Enl', 0, 0],
                    ['Bur', 0, 0],
                ],
                {
                    class: 'srs hover-wrapper-target',
                    child: create_elem({
                        type: 'div',
                        class: 'hover-wrapper below',
                        child: create_elem({ type: 'table' }),
                    }),
                },
            ),
            create_table('left', [['Type'], ['Rad', 0], ['Kan', 0], ['Voc', 0]], { class: 'type' }),
            create_table('left', [['Summary'], ['Pass', 0], ['Fail', 0], ['Acc', 0]], { class: 'summary' }),
            create_table('left', [['Answers'], ['Right', 0], ['Wrong', 0], ['Acc', 0]], {
                class: 'answers hover-wrapper-target',
                child: create_elem({
                    type: 'div',
                    class: 'hover-wrapper above',
                    child: 'The total number of correct and incorrect answers',
                }),
            }),
        )
        return popper
    }

    // Creates a new minimap for the popper
    function create_minimap(type, data) {
        let settings = wkof.settings[script_id]
        let multiplier = 2
        return new Heatmap(
            {
                type: 'day',
                id: 'hours-map',
                first_date: Date.parse(new Date(data[0][0] - settings.general.day_start * msh).toDateString()),
                last_date: Date.parse(new Date(data[0][0] + msd - settings.general.day_start * msh).toDateString()),
                day_start: settings.general.day_start,
                day_hover_callback: (date, day_data) => {
                    let type2 = type
                    if (type2 === 'reviews' && Date.parse(date.join('-')) > Date.now() && day_data.counts.forecast)
                        type2 = 'forecast'
                    let string = [
                        `${(day_data.counts[type2] || 0).toLocaleString()} ${
                            type2 === 'forecast'
                                ? 'reviews upcoming'
                                : day_data.counts[type2] === 1
                                ? type2.slice(0, -1)
                                : type2
                        } at ${date[3]}:00`,
                    ]
                    if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
                        string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
                    return string
                },
                color_callback: (date, day_data) => color_picker(type, date, day_data, 2),
            },
            data,
        )
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    // Automatically determines what the user's interval bounds should be using quantiles
    function auto_range(stats, forecast_items) {
        let settings = wkof.settings[script_id]
        // Forecast needs to have some calculations done
        let forecast_days = {}
        for (let [date] of Object.values(forecast_items)) {
            let string = new Date(date).toDateString()
            if (!forecast_days[string]) forecast_days[string] = 1
            else forecast_days[string]++
        }
        let forecast_mean = forecast_items.length / Object.keys(forecast_days).length
        let forecast_sd =
            Math.sqrt(
                (1 / (forecast_items.length / forecast_mean)) *
                    Object.values(forecast_days)
                        .map((x) => Math.pow(x - forecast_mean, 2))
                        .reduce((a, b) => a + b, 0),
            ) || 1
        // Get intervals
        let range = (length, gradient, mean, sd) => [
            1,
            ...Array((length < 2 ? 2 : length) - 2)
                .fill(null)
                .map(
                    (_, i) =>
                        Math.round(ifcdf(((gradient ? 0.9 : 1) * (i + 1)) / (length - (gradient ? 1 : 0)), mean, sd)) ||
                        1,
                ),
        ]
        let reviews = range(
            settings.reviews.colors.length,
            settings.reviews.gradient,
            stats.reviews.average[1],
            stats.reviews.average[2],
        )
        let lessons = range(
            settings.lessons.colors.length,
            settings.lessons.gradient,
            stats.lessons.average[1],
            stats.lessons.average[2],
        )
        let forecast = range(settings.forecast.colors.length, settings.forecast.gradient, forecast_mean, forecast_sd)
        if (settings.reviews.auto_range)
            for (let i = 1; i < settings.reviews.colors.length; i++) settings.reviews.colors[i][0] = reviews[i - 1]
        if (settings.lessons.auto_range)
            for (let i = 1; i < settings.lessons.colors.length; i++) settings.lessons.colors[i][0] = lessons[i - 1]
        if (settings.forecast.auto_range)
            for (let i = 1; i < settings.forecast.colors.length; i++) settings.forecast.colors[i][0] = forecast[i - 1]
        wkof.Settings.save(script_id)
    }

    // Picks colors for the heatmap days
    function color_picker(type, date, day_data, multiplier = 1) {
        let settings = wkof.settings[script_id]
        let type2 = type
        if (
            type2 === 'reviews' &&
            new Date(date[0], date[1] - 1, date[2], 0, 0).getTime() > Date.now() - msh * settings.general.day_start &&
            day_data.counts.forecast
        )
            type2 = 'forecast'
        let colors = settings[type2].colors
        // If gradients are not enabled, use intervals
        if (!settings[type2].gradient) {
            for (let [bound, color] of colors.slice().reverse()) {
                if (day_data.counts[type2] * multiplier >= bound) {
                    return color
                }
            }
            return colors[0][1]
            // If gradients are enabled, interpolate colors
        } else {
            // Multiplier is used for minimap to get better ranges
            if (!day_data.counts[type2] * multiplier) return colors[0][1]
            if (day_data.counts[type2] * multiplier >= colors[colors.length - 1][0]) return colors[colors.length - 1][1]
            for (let i = 2; i < colors.length; i++) {
                if (day_data.counts[type2] * multiplier <= colors[i][0]) {
                    let percentage =
                        (day_data.counts[type2] * multiplier - colors[i - 1][0]) / (colors[i][0] - colors[i - 1][0])
                    return interpolate_color(colors[i - 1][1], colors[i][1], percentage)
                }
            }
        }
    }

    // Toggles between lessons and reviews
    function toggle_visible_map() {
        let heatmap = document.getElementById('heatmap')
        heatmap.classList.toggle('reviews')
        wkof.settings[script_id].other.visible_map = heatmap.classList.contains('reviews') ? 'reviews' : 'lessons'
        wkof.Settings.save(script_id)
    }

    // Toggles the visibility of the years
    function toggle_year(event) {
        let visible_years = wkof.settings[script_id].other.visible_years
        let year_elem = event.target.closest('.year')
        let up = event.target.closest('.toggle-year').classList.contains('up')
        let year = Number(year_elem.getAttribute('data-year'))
        let future = year > new Date().getFullYear()
        let type = year_elem.classList.contains('reviews') ? 'reviews' : 'lessons'
        if (up || (!up && future)) {
            if (year == new Date().getFullYear()) {
                visible_years[type][year + 1] = true
                year_elem.nextElementSibling.classList.remove('hidden')
                year_elem.parentElement.classList.add('visible-future')
            } else {
                visible_years[type][year] = false
                year_elem.classList.add('hidden')
                if (!up && future) year_elem.parentElement.classList.remove('visible-future')
            }
        } else {
            visible_years[type][year - 1] = true
            year_elem.previousElementSibling.classList.remove('hidden')
        }
        // Make sure at least one year is visible
        if (!Object.values(visible_years[type]).find((a) => a == true)) {
            visible_years[type][year] = true
        }
        wkof.Settings.save(script_id)
    }

    // Updates the popper with new info
    async function update_popper(event, type, title, info, minimap_data, burns, time) {
        let items_id = await wkof.ItemData.get_index(await wkof.ItemData.get_items('include_hidden'), 'subject_id')
        let popper = document.getElementById('popper')
        // Get info
        let levels = new Array(61).fill(0)
        levels[0] = new Array(6).fill(0)
        let item_types = { rad: 0, kan: 0, voc: 0 }
        for (let id of info.lists[type + '-ids']) {
            let item = items_id[id]
            if (!item) continue
            levels[0][Math.floor((item.data.level - 1) / 10)]++
            levels[item.data.level]++
            const type = item.object === 'kana_vocabulary' ? 'voc' : item.object.slice(0, 3)
            item_types[type]++
        }
        let srs = new Array(10).fill(null).map((_) => [0, 0])
        for (let i = 1; i < 10; i++) {
            srs[i][0] = info.counts[type + '-srs1-' + i] || 0
            srs[i][1] = info.counts[type + '-srs2-' + i] || 0
        }
        let srs_counter = (index, start, end) =>
            srs.map((a, i) => (i >= start ? (i <= end ? a[index] : 0) : 0)).reduce((a, b) => a + b, 0)
        srs[0] = [
            [srs_counter(0, 1, 4), srs_counter(1, 1, 4)],
            [srs_counter(0, 5, 6), srs_counter(1, 5, 6)],
            srs[7],
            srs[8],
            srs[9],
        ]
        let srs_diff = Object.entries(srs.slice(1)).reduce((a, b) => a + b[0] * (b[1][1] - b[1][0]), 0)
        let pass = [
            info.counts.pass,
            info.counts.reviews - info.counts.pass,
            Math.floor((info.counts.pass / info.counts.reviews) * 100),
        ]
        let answers = [
            info.counts.reviews * 2 - item_types.rad,
            info.counts.incorrect,
            Math.floor(
                ((info.counts.reviews * 2 - item_types.rad) /
                    (info.counts.incorrect + info.counts.reviews * 2 - item_types.rad)) *
                    100,
            ),
        ]
        let item_elems = []
        const ids = [...new Set(info.lists[type + '-ids'])]
        const svgs = {}
        const svgPromises = []
        for (const id of ids) {
            if (!items_id[id] || items_id[id]?.data?.characters) continue
            svgPromises.push(
                wkof
                    .load_file(
                        items_id[id].data.character_images.find(
                            (a) => a.content_type == 'image/svg+xml' && a.metadata.inline_styles,
                        ).url,
                    )
                    .then((svg) => {
                        let svgElem = document.createElement('span')
                        svgElem.innerHTML = svg.replace(/<svg /, `<svg class="radical-svg" `)
                        svgs[id] = svgElem.firstChild
                    }),
            )
        }
        await Promise.allSettled(svgPromises)
        for (let id of ids) {
            let item = items_id[id]
            if (!item) continue
            let burn = burns.includes(id)
            const type = item.object === 'kana_vocabulary' ? 'vocabulary' : item.object
            item_elems.push(
                create_elem({
                    type: 'a',
                    class: 'item ' + type + ' hover-wrapper-target' + (burn ? ' burn' : ''),
                    href: item.data.document_url,
                    children: [
                        create_elem({
                            type: 'div',
                            class: 'hover-wrapper above',
                            children: [
                                create_elem({
                                    type: 'a',
                                    class: 'characters',
                                    href: item.data.document_url,
                                    child: item.data.characters || svgs[id].cloneNode(true),
                                }),
                                create_table(
                                    'left',
                                    [
                                        ['Meanings', item.data.meanings.map((i) => i.meaning).join(', ')],
                                        [
                                            'Readings',
                                            item.data.readings
                                                ? item.data.readings.map((i) => i.reading).join('、 ')
                                                : '-',
                                        ],
                                        ['Level', item.data.level],
                                    ],
                                    { class: 'info' },
                                ),
                            ],
                        }),
                        create_elem({
                            type: 'a',
                            class: 'characters',
                            child: item.data.characters || svgs[id].cloneNode(true),
                        }),
                    ],
                }),
            )
        }
        let time_str = ms_to_hms(time)
        let count = info.lists[type + '-ids'].length
        let count_str =
            (type === 'forecast' ? 'upcoming review' : type.slice(0, type.length - 1)) + (count === 1 ? '' : 's')
        // Populate popper
        popper.className = type
        popper.querySelector('.date').innerText = title
        popper.querySelector('.count').innerText = count.toLocaleString() + ' ' + count_str
        popper.querySelector('.time').innerText = type == 'forecast' ? '' : time_str ? ' (' + time_str + ')' : ''
        popper.querySelector('.score > span').innerText = (srs_diff < 0 ? '' : '+') + srs_diff.toLocaleString()
        popper.querySelectorAll('.levels .hover-wrapper > *').forEach((e) => e.remove())
        popper.querySelectorAll('.levels > tr > td').forEach((e, i) => {
            e.innerText = levels[0][i].toLocaleString()
            e.parentElement.setAttribute('data-count', levels[0][i])
            e.parentElement.children[0].append(
                create_table(
                    'left',
                    levels
                        .slice(1)
                        .map((a, j) => [j + 1, a.toLocaleString()])
                        .filter((a) => Math.floor((a[0] - 1) / 10) == i && a[1] != 0),
                ),
            )
        })
        popper.querySelectorAll('.srs > tr > td').forEach((e, i) => {
            e.innerText = srs[0][Math.floor(i / 2)][i % 2].toLocaleString()
        })
        popper
            .querySelector('.srs .hover-wrapper table')
            .replaceWith(
                create_table('left', [
                    ['SRS'],
                    ['Before / After'],
                    ...srs
                        .slice(1)
                        .map((a, i) => [
                            ['App 1', 'App 2', 'App 3', 'App 4', 'Gur 1', 'Gur 2', 'Mas', 'Enl', 'Bur'][i],
                            ...a.map((_) => _.toLocaleString()),
                        ]),
                ]),
            )
        popper.querySelectorAll('.type td').forEach((e, i) => {
            e.innerText = item_types[['rad', 'kan', 'voc'][i]].toLocaleString()
        })
        popper.querySelectorAll('.summary td').forEach((e, i) => {
            e.innerText = (pass[i] || 0).toLocaleString()
        })
        popper.querySelectorAll('.answers td').forEach((e, i) => {
            e.innerText = (answers[i] || 0).toLocaleString()
        })
        popper.querySelector('.items').replaceWith(create_elem({ type: 'div', class: 'items', children: item_elems }))
        popper.querySelector('.minimap > .hours-map').replaceWith(create_minimap(type, minimap_data).maps.day)
        popper.style.top = event.pageY + 50 + 'px'
        popper.classList.add('popped')
        wkof.settings[script_id].other.times_popped++
        wkof.Settings.save(script_id)
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    // Returns the function that handles clicks on days. Wrapped for data storage
    function day_click(data) {
        function event_handler(event) {
            let settings = wkof.settings[script_id]
            let elem = event.target
            if (elem.classList.contains('day')) {
                let date = elem.getAttribute('data-date').split('-')
                date = new Date(date[0], date[1] - 1, date[2], 0, 0)
                let type = elem.closest('.view').classList.contains('reviews')
                    ? date < new Date()
                        ? 'reviews'
                        : 'forecast'
                    : 'lessons'
                if (Object.keys(elem.info.lists).length) {
                    let title = `${date.toDateString().slice(4)} ${kanji_day(date.getDay())}`
                    let today = new Date(new Date().toDateString()).getTime()
                    let offset = wkof.settings[script_id].general.day_start * msh
                    let day_data = data[type].filter(
                        (a) => a[0] >= date.getTime() + offset && a[0] < date.getTime() + msd + offset,
                    )
                    let minimap_data = cook_data(type, day_data)
                    let burns = day_data
                        .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
                        .map((item) => item[1])
                    let time = minimap_data
                        .map((a, i) => a[0] - (minimap_data[i - 1] || [0])[0])
                        .filter((a) => a < settings.general.session_limit * 60 * 1000)
                        .reduce((a, b) => a + b, 0)
                    update_popper(event, type, title, elem.info, minimap_data, burns, time)
                }
            }
        }
        return event_handler
    }

    // Returns the function that handles click and drag. Wrapped for data storage
    function click_and_drag(data) {
        let down,
            first_day,
            first_date,
            marked = []
        function event_handler(event) {
            let elem = event.target
            // If event concerns a day element, proceed
            if (elem.classList.contains('day')) {
                let date = elem.getAttribute('data-date').split('-')
                date = new Date(date[0], date[1] - 1, date[2], 0, 0)
                let type = elem.closest('.view').classList.contains('reviews')
                    ? date < new Date()
                        ? 'reviews'
                        : 'forecast'
                    : 'lessons'
                // Start selection
                if (event.type === 'mousedown') {
                    event.preventDefault()
                    down = true
                    first_day = elem
                    first_date = new Date(elem.getAttribute('data-date'))
                }
                // End selection
                if (event.type === 'mouseup') {
                    if (first_day !== elem) {
                        // Gather the data then update popper
                        let second_date = new Date(elem.getAttribute('data-date'))
                        let start_date = first_date < second_date ? first_date : second_date
                        let end_date = first_date < second_date ? second_date : first_date
                        type = elem.closest('.view').classList.contains('reviews')
                            ? start_date < new Date()
                                ? 'reviews'
                                : 'forecast'
                            : 'lessons'
                        let title = `${start_date.toDateString().slice(4)} ${kanji_day(
                            start_date.getDay(),
                        )} - ${end_date.toDateString().slice(4)} ${kanji_day(end_date.getDay())}`
                        let today = new Date(new Date().toDateString()).getTime()
                        let offset = wkof.settings[script_id].general.day_start * msh
                        let day_data = data[type].filter(
                            (a) => a[0] > start_date.getTime() + offset && a[0] < end_date.getTime() + msd + offset,
                        )
                        let mapped_day_data = day_data.map((a) => [
                            today + new Date(a[0]).getHours() * msh + wkof.settings[script_id].general.day_start * msh,
                            ...a.slice(1),
                        ])
                        let minimap_data = cook_data(type, mapped_day_data)
                        let popper_info = { counts: {}, lists: {} }
                        for (let item of minimap_data) {
                            for (let [key, value] of Object.entries(item[1])) {
                                if (!popper_info.counts[key]) popper_info.counts[key] = 0
                                popper_info.counts[key] += value
                            }
                            for (let [key, value] of Object.entries(item[2])) {
                                if (!popper_info.lists[key]) popper_info.lists[key] = []
                                popper_info.lists[key].push(value)
                            }
                        }
                        let burns = day_data
                            .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
                            .map((item) => item[1])
                        let time = day_data
                            .map((a, i) => Math.floor((a[0] - (day_data[i - 1] || [0])[0]) / (60 * 1000)))
                            .filter((a) => a < 10)
                            .reduce((a, b) => a + b, 0)
                        update_popper(event, type, title, popper_info, minimap_data, burns, time)
                        wkof.settings[script_id].other.times_dragged++
                    }
                }
                // Update selection
                if (event.type === 'mouseover' && down) {
                    let view = document.querySelector('#heatmap .view.' + (type === 'forecast' ? 'reviews' : type))
                    if (!view) return
                    for (let m of marked) {
                        m.classList.remove('selected')
                    }
                    marked = []
                    elem.classList.add('selected')
                    marked.push(elem)
                    let d = new Date(first_date.getTime())
                    while (d.toDateString() !== date.toDateString()) {
                        let e = view.querySelector(
                            `.day[data-date="${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}"]`,
                        )
                        e.classList.add('selected')
                        marked.push(e)
                        d.setDate(d.getDate() + (d < date ? 1 : -1))
                    }
                }
            }
            // If mouse is let go, remove selection
            if (event.type === 'mouseup') {
                down = false
                for (let m of marked) {
                    m.classList.remove('selected')
                }
                marked = []
            }
        }
        return event_handler
    }

    /*-------------------------------------------------------------------------------------------------------------------------------*/

    // Shorthand for creating new elements. Keys that do not have a special function will be added as attributes
    function create_elem(config) {
        let div = document.createElement(config.type)
        for (let [attr, value] of Object.entries(config)) {
            if (attr === 'type') continue
            else if (attr === 'child') div.append(value)
            else if (attr === 'children') div.append(...value)
            else if (attr === 'value') div.value = value
            else if (attr === 'input') div.setAttribute('type', value)
            else if (attr === 'onclick') div.onclick = value
            else if (attr === 'callback') continue
            else div.setAttribute(attr, value)
        }
        if (config.callback) config.callback(div)
        return div
    }

    // Creates a table from a matrix
    function create_table(header, data, table_attr, tr_hover) {
        let table = create_elem(Object.assign({ type: 'table' }, table_attr))
        for (let [i, row] of Object.entries(data)) {
            let tr_config = { type: 'tr' }
            if (tr_hover) {
                tr_config.class = 'hover-wrapper-target'
                tr_config.child = create_elem({ type: 'div', class: 'hover-wrapper below' })
            }
            let tr = create_elem(tr_config)
            for (let [j, cell] of Object.entries(row)) {
                let cell_type = (header == 'top' && i == 0) || (header == 'left' && j == 0) ? 'th' : 'td'
                tr.append(create_elem({ type: cell_type, child: cell }))
            }
            table.append(tr)
        }
        return table
    }

    // Returns the kanij for the day
    function kanji_day(day) {
        return ['日', '月', '火', '水', '木', '金', '土'][day]
    }
    // Converts minutes to a timestamp string "#h #m"
    function m_to_hm(minutes) {
        return Math.floor(minutes / 60) + 'h ' + Math.floor(minutes % 60) + 'm'
    }
    // Converts ms to a timestamp string "#h #m #s" where only the first two non-zero values are included
    function ms_to_hms(ms) {
        const hms = [
            [ms + 1, msh, 'h'],
            [msh, 60 * 1000, 'm'],
            [60 * 1000, 1000, 's'],
        ]
        return hms
            .map((a) => Math.floor((ms % a[0]) / a[1]) + a[2])
            .filter((a) => a[0] !== '0')
            .slice(0, 2)
            .join(' ')
    }

    // Capitalizes the first character in a string. "proper" → "Proper"
    String.prototype.toProper = function () {
        return this.slice(0, 1).toUpperCase() + this.slice(1)
    }
    // Returns a hex color between the left and right hex colors
    function interpolate_color(left, right, index) {
        if (isNaN(index)) return left
        left = hex_to_rgb(left)
        right = hex_to_rgb(right)
        let result = [0, 0, 0]
        for (let i = 0; i < 3; i++) result[i] = Math.round(left[i] + index * (right[i] - left[i]))
        return rgb_to_hex(result)
    }
    // Converts a hex color to rgb
    function hex_to_rgb(hex) {
        let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
        return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
    }
    // Converts an rgb color to hex
    function rgb_to_hex(cols) {
        let rgb = cols[2] | (cols[1] << 8) | (cols[0] << 16)
        return '#' + (0x1000000 + rgb).toString(16).slice(1)
    }
    // Crude approximation of inverse folded cumulative distribution function
    // Used for the quantiles in auto-ranging
    function ifcdf(p, m, sd) {
        // Folded cumulative distribution function
        function fcdf(x, mean, sd) {
            // Error function
            function erf(x) {
                let sign = x >= 0 ? 1 : -1
                x = Math.abs(x)
                let a1 = 0.254829592,
                    a2 = -0.284496736
                let a3 = 1.421413741,
                    a4 = -1.453152027
                let a5 = 1.061405429,
                    p = 0.3275911
                let t = 1 / (1 + p * x)
                let y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x)
                return sign * y
            }
            return 0.5 * (erf((x + mean) / (sd * Math.sqrt(2))) + erf((x - mean) / (sd * Math.sqrt(2))))
        }
        let p2 = 0,
            items = 0,
            step = Math.ceil(sd / 10)
        while (p2 < p) {
            items += step
            p2 = fcdf(items, m, sd)
        }
        return items
    }

    // Fires a custom event on an element
    function fire_event(event_name, elem) {
        const event = document.createEvent('Event')
        event.initEvent(event_name, true, true)
        elem.dispatchEvent(event)
    }
})(window.wkof, window.review_cache, window.Heatmap, window.Icons)