Adds review and lesson heatmaps to the dashboard.
// ==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 r###lt = [0, 0, 0] for (let i = 0; i < 3; i++) r###lt[i] = Math.round(left[i] + index * (right[i] - left[i])) return rgb_to_hex(r###lt) } // Converts a hex color to rgb function hex_to_rgb(hex) { let r###lt = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return [parseInt(r###lt[1], 16), parseInt(r###lt[2], 16), parseInt(r###lt[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)