🏠 返回首頁 

Greasy Fork is available in English.

Wanikani Heatmap

Adds review and lesson heatmaps to the dashboard.

  1. // ==UserScript==
  2. // @name Wanikani Heatmap
  3. // @namespace http://tampermonkey.net/
  4. // @version 3.1.11
  5. // @description Adds review and lesson heatmaps to the dashboard.
  6. // @author Kumirei
  7. // @include /^https://(www|preview).wanikani.com/(dashboard)?$/
  8. // @match https://www.wanikani.com/*
  9. // @match https://preview.wanikani.com/*
  10. // @require https://greasyfork.org/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1417568
  11. // @require https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=1193344
  12. // @require https://greasyfork.org/scripts/410910-heatmap/code/Heatmap.js?version=1251299
  13. // @grant none
  14. // ==/UserScript==
  15. ;(function (wkof, review_cache, Heatmap, Icons) {
  16. const CSS_COMMIT = '61a780cee6f08eb3a4f8f37068c1e6ce29762e96'
  17. let script_id = 'heatmap3'
  18. let script_name = 'Wanikani Heatmap'
  19. let msh = 60 * 60 * 1000,
  20. msd = 24 * msh // Milliseconds in hour and day
  21. Icons.addCustomIcons([
  22. [
  23. 'trophy',
  24. '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',
  25. 576,
  26. ],
  27. [
  28. 'inbox',
  29. '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',
  30. ],
  31. ])
  32. /*-------------------------------------------------------------------------------------------------------------------------------*/
  33. var reload // Function to reload the heatmap
  34. // Temporary measure to track reviews while the /reviews endpoint is unavailable
  35. function main() {
  36. if (/www.wanikani.com\/(dashboard)?#?$/.test(window.location.href)) {
  37. // Wait until modules are ready then initiate script
  38. confirm_wkof()
  39. wkof.include('Menu,Settings,ItemData,Apiv2')
  40. wkof.ready('Menu,Settings,ItemData,Apiv2')
  41. .then(load_settings)
  42. .then(load_css)
  43. .then(install_menu)
  44. .then(initiate)
  45. window.addEventListener('turbo:load', async (e) => {
  46. setTimeout(main, 0)
  47. })
  48. }
  49. }
  50. main()
  51. // Fetch necessary data then install the heatmap
  52. async function initiate() {
  53. review_cache.subscribe(do_stuff)
  54. async function do_stuff(reviews) {
  55. reviews ??= []
  56. // Fetch data
  57. let items = await wkof.ItemData.get_items('assignments,include_hidden')
  58. let [forecast, lessons] = get_forecast_and_lessons(items)
  59. if (wkof.settings[script_id].lessons.recover_lessons) {
  60. let recovered_lessons = await get_recovered_lessons(items, reviews, lessons)
  61. lessons = lessons.concat(recovered_lessons).sort((a, b) => (a[0] < b[0] ? -1 : 1))
  62. }
  63. // Create heatmap
  64. reload = function (new_reviews = false) {
  65. // If start date is invalid, set it to the default
  66. if (isNaN(Date.parse(wkof.settings[script_id].general.start_date)))
  67. wkof.settings[script_id].general.start_date = '2012-01-01'
  68. // Get a timestamp for the start date
  69. wkof.settings[script_id].general.start_day =
  70. new Date(wkof.settings[script_id].general.start_date) -
  71. -new Date(wkof.settings[script_id].general.start_date).getTimezoneOffset() * 60 * 1000
  72. setTimeout(() => {
  73. // Make settings dialog respond immediately
  74. let stats = {
  75. reviews: calculate_stats('reviews', reviews),
  76. lessons: calculate_stats('lessons', lessons),
  77. }
  78. auto_range(stats, forecast)
  79. install_heatmap(reviews, forecast, lessons, stats, items)
  80. }, 0)
  81. }
  82. reload()
  83. }
  84. }
  85. /*-------------------------------------------------------------------------------------------------------------------------------*/
  86. function confirm_wkof() {
  87. if (!wkof) {
  88. let response = confirm(
  89. script_name +
  90. ' requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.',
  91. )
  92. if (response)
  93. window.location.href =
  94. 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549'
  95. return
  96. }
  97. }
  98. // Load settings from WKOF
  99. function load_settings() {
  100. let defaults = {
  101. general: {
  102. start_date: '2012-01-01',
  103. week_start: 0,
  104. day_start: 0,
  105. reverse_years: false,
  106. segment_years: true,
  107. zero_gap: false,
  108. month_labels: 'all',
  109. day_labels: true,
  110. session_limit: 10,
  111. now_indicator: true,
  112. color_now_indicator: '#ff0000',
  113. level_indicator: true,
  114. color_level_indicator: '#ffffff',
  115. position: 2,
  116. theme: 'dark',
  117. },
  118. reviews: {
  119. gradient: false,
  120. auto_range: true,
  121. },
  122. lessons: {
  123. gradient: false,
  124. auto_range: true,
  125. count_zeros: false,
  126. recover_lessons: false,
  127. },
  128. forecast: {
  129. gradient: false,
  130. auto_range: true,
  131. },
  132. other: {
  133. visible_years: { reviews: {}, lessons: {} },
  134. visible_map: 'reviews',
  135. times_popped: 0,
  136. times_dragged: 0,
  137. ported: false,
  138. },
  139. }
  140. return wkof.Settings.load(script_id, defaults).then((settings) => {
  141. // Workaround for defaults modifying existing settings
  142. if (!settings.reviews.colors?.length)
  143. settings.reviews.colors = [
  144. [0, '#747474'],
  145. [1, '#ade4ff'],
  146. [100, '#82c5e6'],
  147. [200, '#57a5cc'],
  148. [300, '#2b86b3'],
  149. [400, '#006699'],
  150. ]
  151. if (!settings.lessons.colors?.length)
  152. settings.lessons.colors = [
  153. [0, '#747474'],
  154. [1, '#ff8aa1'],
  155. [100, '#e46e9e'],
  156. [200, '#c8539a'],
  157. [300, '#ad3797'],
  158. [400, '#911b93'],
  159. ]
  160. if (!settings.forecast.colors?.length)
  161. settings.forecast.colors = [
  162. [0, '#747474'],
  163. [1, '#aaaaaa'],
  164. [100, '#bfbfbf'],
  165. [200, '#d5d5d5'],
  166. [300, '#eaeaea'],
  167. [400, '#ffffff'],
  168. ]
  169. // Load settings from old script if possible
  170. if (!settings.other.ported) port_settings(settings)
  171. migrate_settings(settings)
  172. // Make sure current year is visible
  173. for (let type of ['reviews', 'lessons']) {
  174. wkof.settings[script_id].other.visible_years[type][new Date().getFullYear()] = true
  175. }
  176. wkof.Settings.save(script_id)
  177. return settings
  178. })
  179. }
  180. // Loads heatmap and jQuery datepicker CSS
  181. function load_css() {
  182. // Heatmap CSS
  183. const heatmapRepo = `//raw.githubusercontent.com/Kumirei/Userscripts/${CSS_COMMIT}/Wanikani/Heatmap`
  184. wkof.load_css(`${heatmapRepo}/Heatmap/Heatmap.css`, true)
  185. wkof.load_css(`${heatmapRepo}/heatmap3.css`, true)
  186. }
  187. // Installs the settings button in the menu
  188. function install_menu() {
  189. let config = {
  190. name: script_id,
  191. submenu: 'Settings',
  192. title: 'Heatmap',
  193. on_click: open_settings,
  194. }
  195. wkof.Menu.insert_script_link(config)
  196. }
  197. // Add stuff to the settings dialog before opening
  198. let applied // Keeps track of whether the settings have been applied
  199. function modify_settings(dialog) {
  200. // Make start-date a jQuery datepicker
  201. //window.jQuery(dialog[0].querySelector('#'+script_id+'_start_date')).datepicker({dateFormat: "yy-mm-dd",changeYear: true,yearRange: "2012:+0"});
  202. // Add apply button
  203. applied = false
  204. let apply = create_elem({
  205. type: 'button',
  206. class: 'ui-button ui-corner-all ui-widget',
  207. child: 'Apply',
  208. onclick: (e) => {
  209. applied = true
  210. reload()
  211. },
  212. })
  213. dialog[0].nextElementSibling
  214. .getElementsByClassName('ui-dialog-buttonset')[0]
  215. .insertAdjacentElement('afterbegin', apply)
  216. // Updates the color labels with new hex values
  217. let update_label = function (input) {
  218. if (!input.nextElementSibling)
  219. input.insertAdjacentElement(
  220. 'afterend',
  221. create_elem({ type: 'div', class: 'color-label', child: input.value }),
  222. )
  223. else input.nextElementSibling.innerText = input.value
  224. if (!Math.round(hex_to_rgb(input.value).reduce((a, b) => a + b / 3, 0) / 255 - 0.15))
  225. input.nextElementSibling.classList.remove('light-color')
  226. else input.nextElementSibling.classList.add('light-color')
  227. }
  228. // Add color settings
  229. dialog[0]
  230. .querySelectorAll('#' + script_id + '_general ~ div .wkof_group > div:nth-of-type(2)')
  231. .forEach((elem, i) => {
  232. let type = ['reviews', 'lessons', 'forecast'][i]
  233. // Update the settings object with data from the settings dialog
  234. let update_color_settings = (_) => {
  235. wkof.settings[script_id][type].colors = []
  236. Array.from(elem.nextElementSibling.children[1].children).forEach((child, i) => {
  237. wkof.settings[script_id][type].colors.push([
  238. child.children[0].children[0].value,
  239. child.children[1].children[0].value,
  240. ])
  241. })
  242. }
  243. // Creates a new interval setting
  244. let create_row = (value, color) => {
  245. return create_elem({
  246. type: 'div',
  247. class: 'row',
  248. children: [
  249. create_elem({
  250. type: 'div',
  251. class: 'text',
  252. child: create_elem({ type: 'input', input: 'number', value: value }),
  253. }),
  254. create_elem({
  255. type: 'div',
  256. class: 'color',
  257. child: create_elem({
  258. type: 'input',
  259. input: 'color',
  260. value: color,
  261. callback: (e) => e.addEventListener('change', (_) => update_label(e)),
  262. }),
  263. callback: (e) => update_label(e.children[0]),
  264. }),
  265. create_elem({
  266. type: 'div',
  267. class: 'delete',
  268. child: create_elem({
  269. type: 'button',
  270. onclick: (e) => {
  271. e.target.closest('.row').remove()
  272. update_color_settings()
  273. },
  274. child: Icons.customIcon('trash'),
  275. }),
  276. }),
  277. ],
  278. })
  279. }
  280. // Creates the interface for color settings
  281. let panel = create_elem({
  282. type: 'div',
  283. class: 'right',
  284. children: [
  285. create_elem({
  286. type: 'button',
  287. class: 'adder',
  288. onclick: (e) => {
  289. e.target.nextElementSibling.append(create_row(0, '#ffffff'))
  290. update_color_settings()
  291. },
  292. child: 'Add interval',
  293. }),
  294. create_elem({ type: 'div', class: 'row panel' }),
  295. ],
  296. })
  297. // Update the settings when they change
  298. panel.addEventListener('change', update_color_settings)
  299. // Add the existing settings
  300. for (let [value, color] of wkof.settings[script_id][type].colors)
  301. panel.children[1].append(create_row(value, color))
  302. // Make sure that reviews and forecast have the same zero-color
  303. if (i == 0 || i == 2)
  304. panel.children[1].children[0].addEventListener('change', (e) => {
  305. let input = e.target
  306. .closest('#' + script_id + '_tabs')
  307. .querySelector(
  308. '#' +
  309. script_id +
  310. '_' +
  311. (i == 0 ? 'forecast' : 'reviews') +
  312. ' .panel > .row:first-child .color input',
  313. )
  314. if (input.value != e.target.value) {
  315. input.value = e.target.value
  316. input.dispatchEvent(new Event('change'))
  317. wkof.settings[script_id][i == 0 ? 'forecast' : 'reviews'].colors[0][1] = e.target.value
  318. }
  319. })
  320. // Install
  321. elem.insertAdjacentElement('afterend', panel)
  322. })
  323. // Disable the first interval's bound input so that it can't be changed from 0
  324. dialog[0]
  325. .querySelectorAll('#' + script_id + '_general ~ div .panel .row:first-child .text input')
  326. .forEach((elem) => (elem.disabled = true))
  327. // Add labels to all color inputs
  328. dialog[0].querySelectorAll('#' + script_id + '_general input[type="color"]').forEach((input) => {
  329. input.addEventListener('change', () => update_label(input))
  330. update_label(input)
  331. })
  332. // Add functionality to review inserter
  333. dialog[0].querySelector('#insert_reviews_button').addEventListener('click', (event) => {
  334. const date = dialog[0].querySelector('#insert_reviews_date').value
  335. const count = Number(dialog[0].querySelector('#insert_reviews_count').value)
  336. const spr = Number(dialog[0].querySelector('#insert_reviews_time').value) || 0 // Seconds per review
  337. if (!date || !count) return
  338. const mspr = spr * 1000 // MS per review
  339. const dayStart = wkof.settings[script_id].general.day_start
  340. const startHour = Math.floor(dayStart)
  341. const startMin = Math.floor((dayStart % 1) * 60)
  342. const time = Date.parse(date + `T${String(startHour).padStart(2, 0)}:${String(startMin).padStart(2, 0)}`)
  343. const reviews = new Array(count).fill(null).map((_, i) => [time + i * mspr, 1, 1, 0, 0])
  344. review_cache.insert(reviews)
  345. })
  346. }
  347. // Open the settings dialog
  348. function open_settings() {
  349. let config = {
  350. script_id: script_id,
  351. title: 'Heatmap',
  352. on_save: (_) => (applied = true),
  353. on_close: reload_on_change,
  354. content: {
  355. tabs: {
  356. type: 'tabset',
  357. content: {
  358. general: {
  359. type: 'page',
  360. label: 'General',
  361. hover_tip: 'Settings pertaining to the general functions of the script',
  362. content: {
  363. control: {
  364. type: 'group',
  365. label: 'Control',
  366. content: {
  367. position: {
  368. type: 'dropdown',
  369. label: 'Position',
  370. default: 2,
  371. hover_tip: 'Where on the dashboard to install the heatmap',
  372. content: {
  373. 0: 'Top',
  374. 1: 'Below forecast',
  375. 2: 'Below SRS',
  376. 3: 'Below panels',
  377. 4: 'Bottom',
  378. },
  379. path: '@general.position',
  380. },
  381. start_date: {
  382. type: 'input',
  383. subtype: 'date',
  384. label: 'Start date',
  385. default: '2012-01-01',
  386. hover_tip: 'All data before this date will be ignored',
  387. path: '@general.start_date',
  388. },
  389. week_start: {
  390. type: 'dropdown',
  391. label: 'First day of the week',
  392. default: 0,
  393. hover_tip: 'Determines which day of the week is at the top of the heatmaps',
  394. content: {
  395. 0: 'Monday',
  396. 1: 'Tuesday',
  397. 2: 'Wednesday',
  398. 3: 'Thursday',
  399. 4: 'Friday',
  400. 5: 'Saturday',
  401. 6: 'Sunday',
  402. },
  403. path: '@general.week_start',
  404. },
  405. day_start: {
  406. type: 'number',
  407. label: 'New day starts at',
  408. default: 0,
  409. placeholder: '(hours after midnight)',
  410. hover_tip:
  411. 'Offset for those who tend to stay up after midnight. If you want the new day to start at 4 AM, input 4.',
  412. path: '@general.day_start',
  413. },
  414. session_limit: {
  415. type: 'number',
  416. label: 'Session time limit (minutes)',
  417. default: 10,
  418. placeholder: '(minutes)',
  419. hover_tip:
  420. 'Max number of minutes between review/lesson items to still count within the same session',
  421. path: '@general.session_limit',
  422. },
  423. theme: {
  424. type: 'dropdown',
  425. label: 'Theme',
  426. default: 'dark',
  427. hover_tip: 'Changes the background color and other things',
  428. content: { light: 'Light', dark: 'Dark', 'breeze-dark': 'Breeze Dark' },
  429. path: '@general.theme',
  430. },
  431. },
  432. },
  433. layout: {
  434. type: 'group',
  435. label: 'Layout',
  436. content: {
  437. reverse_years: {
  438. type: 'checkbox',
  439. label: 'Reverse year order',
  440. default: false,
  441. hover_tip: 'Puts the most recent years on the bottom instead of the top',
  442. path: '@general.reverse_years',
  443. },
  444. segment_years: {
  445. type: 'checkbox',
  446. label: 'Segment year',
  447. default: true,
  448. hover_tip: 'Put a gap between months',
  449. path: '@general.segment_years',
  450. },
  451. zero_gap: {
  452. type: 'checkbox',
  453. label: 'No gap',
  454. default: false,
  455. hover_tip: `Don't display any gap between days`,
  456. path: '@general.zero_gap',
  457. },
  458. day_labels: {
  459. type: 'dropdown',
  460. label: 'Day of week labels',
  461. default: 'english',
  462. hover_tip:
  463. 'Adds letters to the left of the heatmaps indicating which row represents which weekday',
  464. content: { none: 'None', english: 'English', kanji: 'Kanji' },
  465. path: '@general.day_labels',
  466. },
  467. month_labels: {
  468. type: 'dropdown',
  469. label: 'Month labels',
  470. default: 'all',
  471. hover_tip: 'Display month labels above each month',
  472. content: { all: 'All', top: 'Only at the top', none: 'None' },
  473. path: '@general.month_labels',
  474. },
  475. },
  476. },
  477. indicators: {
  478. type: 'group',
  479. label: 'Indicators',
  480. content: {
  481. now_indicator: {
  482. type: 'checkbox',
  483. label: 'Current day indicator',
  484. default: true,
  485. hover_tip: 'Puts a border around the current day',
  486. path: '@general.now_indicator',
  487. },
  488. level_indicator: {
  489. type: 'checkbox',
  490. label: 'Level-up indicators',
  491. default: true,
  492. hover_tip: 'Puts borders around the days on which you leveled up',
  493. path: '@general.level_indicator',
  494. },
  495. color_now_indicator: {
  496. type: 'color',
  497. label: 'Color for current day',
  498. hover_tip: 'The border around the current day will have this color',
  499. default: '#ff0000',
  500. path: '@general.color_now_indicator',
  501. },
  502. color_level_indicator: {
  503. type: 'color',
  504. label: 'Color for level-ups',
  505. hover_tip: 'The borders around level-ups will have this color',
  506. default: '#ffffff',
  507. path: '@general.color_level_indicator',
  508. },
  509. },
  510. },
  511. },
  512. },
  513. reviews: {
  514. type: 'page',
  515. label: 'Reviews',
  516. hover_tip: 'Settings pertaining to the review heatmaps',
  517. content: {
  518. reviews_settings: {
  519. type: 'group',
  520. label: 'Review Settings',
  521. content: {
  522. reviews_section: { type: 'section', label: 'Intervals' },
  523. reviews_auto_range: {
  524. type: 'checkbox',
  525. label: 'Auto range intervals',
  526. default: true,
  527. hover_tip: 'Automatically decide what the intervals should be',
  528. path: '@reviews.auto_range',
  529. },
  530. reviews_gradient: {
  531. type: 'checkbox',
  532. label: 'Use gradients',
  533. default: false,
  534. hover_tip:
  535. 'Interpolate colors based on the exact number of items on that day',
  536. path: '@reviews.gradient',
  537. },
  538. reviews_generate: {
  539. type: 'button',
  540. label: 'Generate colors',
  541. text: 'Generate',
  542. hover_tip: 'Generate new colors from the first and last non-zero interval',
  543. on_click: generate_colors,
  544. },
  545. add_reviews_section: { type: 'section', label: 'Manually Register Reviews' },
  546. reviews_insert: {
  547. type: 'html',
  548. html: `
  549. <div>
  550. <div><label>Date <input id="insert_reviews_date" type="date"/></label></div>
  551. <div><label>Count <input id="insert_reviews_count" type="number" min="0" placeholder="Number of reviews" /></label></div>
  552. <div><label>Seconds Per Review <input id="insert_reviews_time" type="number" min="0" placeholder="seconds" value=10 /></label></div>
  553. <div style="display: flex; justify-content: flex-end;"><button id="insert_reviews_button">Register</button></div>
  554. </div>
  555. `,
  556. },
  557. // reviews_section2: { type: 'section', label: 'Other' },
  558. // reload_button: {
  559. // type: 'button',
  560. // label: 'Reload review data',
  561. // text: 'Reload',
  562. // hover_tip: 'Deletes review cache and starts a new fetch',
  563. // on_click: () => review_cache.reload().then((reviews) => reload(reviews)),
  564. // },
  565. },
  566. },
  567. },
  568. },
  569. lessons: {
  570. type: 'page',
  571. label: 'Lessons',
  572. hover_tip: 'Settings pertaining to the lesson heatmaps',
  573. content: {
  574. lessons_settings: {
  575. type: 'group',
  576. label: 'Lesson Settings',
  577. content: {
  578. lessons_section: { type: 'section', label: 'Intervals' },
  579. lessons_auto_range: {
  580. type: 'checkbox',
  581. label: 'Auto range intervals',
  582. default: true,
  583. hover_tip: 'Automatically decide what the intervals should be',
  584. path: '@lessons.auto_range',
  585. },
  586. lessons_gradient: {
  587. type: 'checkbox',
  588. label: 'Use gradients',
  589. default: false,
  590. hover_tip:
  591. 'Interpolate colors based on the exact number of items on that day',
  592. path: '@lessons.gradient',
  593. },
  594. lessons_generate: {
  595. type: 'button',
  596. label: 'Generate colors',
  597. text: 'Generate',
  598. hover_tip: 'Generate new colors from the first and last non-zero interval',
  599. on_click: generate_colors,
  600. },
  601. lessons_section2: { type: 'section', label: 'Other' },
  602. lessons_count_zeros: {
  603. type: 'checkbox',
  604. label: 'Include zeros in streak',
  605. default: false,
  606. hover_tip: 'Counts days with no lessons available towards the streak',
  607. path: '@lessons.count_zeros',
  608. },
  609. recover_lessons: {
  610. type: 'checkbox',
  611. label: 'Recover reset lessons',
  612. default: false,
  613. hover_tip:
  614. 'Allow the Heatmap to guess when you did lessons for items that have been reset',
  615. path: '@lessons.recover_lessons',
  616. },
  617. },
  618. },
  619. },
  620. },
  621. forecast: {
  622. type: 'page',
  623. label: 'Review Forecast',
  624. hover_tip: 'Settings pertaining to the forecast',
  625. content: {
  626. forecast_settings: {
  627. type: 'group',
  628. label: 'Forecast Settings',
  629. content: {
  630. forecast_section: { type: 'section', label: 'Intervals' },
  631. forecast_auto_range: {
  632. type: 'checkbox',
  633. label: 'Auto range intervals',
  634. default: true,
  635. hover_tip: 'Automatically decide what the intervals should be',
  636. path: '@forecast.auto_range',
  637. },
  638. forecast_gradient: {
  639. type: 'checkbox',
  640. label: 'Use gradients',
  641. default: false,
  642. hover_tip:
  643. 'Interpolate colors based on the exact number of items on that day',
  644. path: '@forecast.gradient',
  645. },
  646. forecast_generate: {
  647. type: 'button',
  648. label: 'Generate colors',
  649. text: 'Generate',
  650. hover_tip: 'Generate new colors from the first and last non-zero interval',
  651. on_click: generate_colors,
  652. },
  653. },
  654. },
  655. },
  656. },
  657. },
  658. },
  659. },
  660. }
  661. let dialog = new wkof.Settings(config)
  662. config.pre_open = (elem) => {
  663. dialog.refresh()
  664. modify_settings(elem)
  665. } // Refresh to populate settings before modifying
  666. delete wkof.settings[script_id].wkofs_active_tabs // Make settings dialog always open in first tab because it is so much taller
  667. dialog.open()
  668. }
  669. // Fetches user's v2 settings if they exist
  670. async function port_settings(settings) {
  671. if (wkof.file_cache.dir['wkof.settings.wanikani_heatmap']) {
  672. let old = await wkof.file_cache.load('wkof.settings.wanikani_heatmap')
  673. settings.general.start_date = old.general.start_date
  674. settings.general.week_start = old.general.week_start ? 0 : 6
  675. settings.general.day_start = old.general.hours_offset
  676. settings.general.reverse_years = old.general.reverse_years
  677. settings.general.segment_years = old.general.segment_years
  678. settings.general.day_labels = old.general.day_labels
  679. settings.general.now_indicator = old.general.today
  680. settings.general.color_now_indicator = old.general.today_color
  681. settings.general.level_indicator = old.general.level_ups
  682. settings.general.color_level_indicator = old.general.level_ups_color
  683. settings.reviews.auto_range = old.reviews.auto_range
  684. settings.reviews.colors = [
  685. [0, '#747474'],
  686. [1, old.reviews.color1],
  687. [old.reviews.interval1, old.reviews.color2],
  688. [old.reviews.interval2, old.reviews.color3],
  689. [old.reviews.interval3, old.reviews.color4],
  690. [old.reviews.interval4, old.reviews.color5],
  691. ]
  692. settings.forecast.colors = [
  693. [0, '#747474'],
  694. [1, old.reviews.forecast_color1],
  695. [old.reviews.interval1, old.reviews.forecast_color2],
  696. [old.reviews.interval2, old.reviews.forecast_color3],
  697. [old.reviews.interval3, old.reviews.forecast_color4],
  698. [old.reviews.interval4, old.reviews.forecast_color5],
  699. ]
  700. settings.forecast.auto_range = old.reviews.auto_range
  701. settings.lessons.colors = [
  702. [0, '#747474'],
  703. [1, old.lessons.color1],
  704. [old.lessons.interval1, old.lessons.color2],
  705. [old.lessons.interval2, old.lessons.color3],
  706. [old.lessons.interval3, old.lessons.color4],
  707. [old.lessons.interval4, old.lessons.color5],
  708. ]
  709. settings.lessons.auto_range = old.lessons.auto_range
  710. settings.lessons.count_zeros = old.lessons.count_zeros
  711. }
  712. settings.other.ported = true
  713. }
  714. // Updates settings if someone has outdated settings
  715. function migrate_settings(settings) {
  716. // Changed day labels from checkbox to dropdown
  717. if (typeof settings.general.day_labels === 'boolean')
  718. settings.general.day_labels = settings.general.day_labels ? 'english' : 'none'
  719. }
  720. // Reload the heatmap if settings have been changed
  721. function reload_on_change(settings) {
  722. if (applied) reload()
  723. }
  724. // Generates new colors for the intervals in the settings dialog
  725. function generate_colors(setting_name) {
  726. // Find the intervals
  727. let type = setting_name.split('_')[0]
  728. let panel = document.getElementById(script_id + '_' + type + '_settings').querySelector('.panel')
  729. let colors = wkof.settings[script_id][type].colors
  730. // Interpolate between first and last non-zero interval
  731. let first = colors[1]
  732. let last = colors[colors.length - 1]
  733. for (let i = 2; i < colors.length; i++) {
  734. colors[i][1] = interpolate_color(first[1], last[1], (i - 1) / (colors.length - 2))
  735. }
  736. // Refresh settings
  737. panel.querySelectorAll('.color input').forEach((input, i) => {
  738. input.value = colors[i][1]
  739. input.dispatchEvent(new Event('change'))
  740. })
  741. }
  742. /*-------------------------------------------------------------------------------------------------------------------------------*/
  743. // Extract upcoming reviews and completed lessons from the WKOF cache
  744. function get_forecast_and_lessons(data) {
  745. let forecast = [],
  746. lessons = []
  747. let time_now = Date.now()
  748. let vacation_offset = time_now - new Date(wkof.user.current_vacation_started_at || time_now)
  749. for (let item of data) {
  750. if (item.assignments?.started_at && item.assignments.unlocked_at) {
  751. // If the assignment has been started add a lesson containing staring date, id, level, and unlock date
  752. lessons.push([
  753. Date.parse(item.assignments.started_at),
  754. item.id,
  755. item.data.level,
  756. Date.parse(item.assignments.unlocked_at),
  757. ])
  758. // If item is in the future and it is not hidden by Wanikani, add the item to the forecast array
  759. if (
  760. item.assignments.available_at &&
  761. Date.parse(item.assignments.available_at) > time_now &&
  762. item.data.hidden_at === null
  763. ) {
  764. // If the assignment is scheduled add a forecast item ready for sending to the heatmap module
  765. let forecast_item = [
  766. Date.parse(item.assignments.available_at) + vacation_offset,
  767. { forecast: 1 },
  768. { 'forecast-ids': item.id },
  769. ]
  770. forecast_item[1]['forecast-srs1-' + item.assignments.srs_stage] = 1
  771. forecast.push(forecast_item)
  772. }
  773. }
  774. }
  775. // Sort lessons by started_at for easy extraction of chronological info
  776. lessons.sort((a, b) => (a[0] < b[0] ? -1 : 1))
  777. return [forecast, lessons]
  778. }
  779. // Fetch recovered lessons from storage or recover lessons then return them
  780. async function get_recovered_lessons(items, reviews, real_lessons) {
  781. if (!wkof.file_cache.dir.recovered_lessons) {
  782. let recovered_lessons = await recover_lessons(items, reviews, real_lessons)
  783. wkof.file_cache.save('recovered_lessons', recovered_lessons)
  784. return recovered_lessons
  785. } else return await wkof.file_cache.load('recovered_lessons')
  786. }
  787. // Use review data to guess when the lesson was done for all reset items
  788. async function recover_lessons(items, reviews, real_lessons) {
  789. // Fetch and prepare data
  790. let resets = await wkof.Apiv2.get_endpoint('resets')
  791. let items_id = wkof.ItemData.get_index(items, 'subject_id')
  792. let delay = 4 * msh
  793. let app1_reviews = reviews
  794. .filter((a) => a[2] == 1)
  795. .map((item) => [item[0] - delay, item[1], items_id[item[1]].data.level, item[0] - delay])
  796. // Check reviews based on reset intervals
  797. let last_date = 0,
  798. recovered_lessons = []
  799. Object.values(resets)
  800. .sort((a, b) => (a.data.confirmed_at < b.data.confirmed_at ? -1 : 1))
  801. .forEach((reset) => {
  802. let ids = {},
  803. date = Date.parse(reset.data.confirmed_at)
  804. // Filter out items not belonging to the current reset period
  805. let reset_reviews = app1_reviews.filter((a) => a[0] > last_date && a[0] < date)
  806. // Choose the earliest App1 review
  807. reset_reviews.forEach((item) => {
  808. if (!ids[item[1]] || ids[item[1]][0] > item[0]) ids[item[1]] = item
  809. })
  810. // Remove items that still have lesson data
  811. real_lessons.filter((a) => a[0] < date).forEach((item) => delete ids[item[1]])
  812. // Save recovered lessons to array
  813. Object.values(ids).forEach((item) => recovered_lessons.push(item))
  814. last_date = date
  815. })
  816. return recovered_lessons
  817. }
  818. // Calculate overall stats for lessons and reviews
  819. function calculate_stats(type, data) {
  820. let settings = wkof.settings[script_id]
  821. let streaks = get_streaks(type, data)
  822. let longest_streak = Math.max(...Object.values(streaks))
  823. let current_streak = streaks[new Date(Date.now() - msh * settings.general.day_start).toDateString()]
  824. let stats = {
  825. total: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
  826. days_studied: [0, 0], // [days studied, percentage]
  827. average: [0, 0, 0], // [average, per studied, standard deviation]
  828. streak: [longest_streak, current_streak], // [longest streak, current streak]
  829. sessions: 0, // Number of sessions
  830. time: [0, 0, 0, 0, 0, 0], // [total, year, month, week, day, today]
  831. days: 0, // Number of days since first review
  832. max_done: [0, 0], // Max done in one day [count, date]
  833. streaks, // Streaks object
  834. }
  835. let last_day = new Date(0) // Last item's date
  836. let today = new Date() // Today
  837. let d = new Date(Date.now() - msd) // 24 hours ago
  838. let week = new Date(Date.now() - 7 * msd) // 7 days ago
  839. let month = new Date(Date.now() - 30 * msd) // 30 days ago
  840. let year = new Date(Date.now() - 365 * msd) // 365 days ago
  841. let last_time = 0 // Last item's timestamp
  842. let done_day = 0 // Total done on the date of the item
  843. let done_days = [] // List of total done on each day
  844. let start_date = new Date(settings.general.start_day) // User's start date
  845. for (let item of data) {
  846. let day = new Date(item[0] - msh * settings.general.day_start)
  847. if (day < start_date) continue // If item is before start, discard it
  848. // If it's a new day
  849. if (last_day.toDateString() != day.toDateString()) {
  850. stats.days_studied[0]++
  851. done_days.push(done_day)
  852. done_day = 0
  853. }
  854. // Update done this day
  855. done_day++
  856. if (done_day > stats.max_done[0]) stats.max_done = [done_day, day.toDateString().replace(/... /, '')]
  857. let minutes = (item[0] - last_time) / 60000
  858. // Update sessions
  859. if (minutes > settings.general.session_limit) {
  860. stats.sessions++
  861. minutes = 0
  862. }
  863. // Update totals
  864. stats.total[0]++
  865. stats.time[0] += minutes
  866. // Done in the last year
  867. if (year < day) {
  868. stats.total[1]++
  869. stats.time[1] += minutes
  870. }
  871. // Done in the last month
  872. if (month < day) {
  873. stats.total[2]++
  874. stats.time[2] += minutes
  875. }
  876. // Done in the last week
  877. if (week < day) {
  878. stats.total[3]++
  879. stats.time[3] += minutes
  880. }
  881. // Done in the last 24 hours
  882. if (d < day) {
  883. stats.total[4]++
  884. stats.time[4] += minutes
  885. }
  886. // Done today
  887. if (today.toDateString() == day.toDateString()) {
  888. stats.total[5]++
  889. stats.time[5] += minutes
  890. }
  891. // Store values for next item
  892. last_day = day
  893. last_time = item[0]
  894. }
  895. // Update averages
  896. done_days.push(done_day)
  897. const day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
  898. const first_date = data?.[0]?.[0] || day_start_adjust
  899. stats.days =
  900. Math.round(
  901. (Date.parse(new Date().toDateString()) -
  902. Math.max(
  903. Date.parse(new Date(first_date).toDateString()),
  904. new Date(settings.general.start_day).getTime(),
  905. )) /
  906. msd,
  907. ) + 1
  908. stats.days_studied[1] = Math.round((stats.days_studied[0] / stats.days) * 100)
  909. stats.average[0] = Math.round(stats.total[0] / stats.days)
  910. stats.average[1] = Math.round(stats.total[0] / stats.days_studied[0])
  911. stats.average[2] = Math.sqrt(
  912. (1 / stats.days_studied[0]) *
  913. done_days.map((x) => Math.pow(x - stats.average[1], 2)).reduce((a, b) => a + b, 0),
  914. )
  915. return stats
  916. }
  917. // Finds streaks
  918. function get_streaks(type, data) {
  919. let settings = wkof.settings[script_id]
  920. let day_start_adjust = msh * settings.general.day_start // Adjust for the user's start of day setting
  921. // Initiate dates
  922. let streaks = {},
  923. zeros = {}
  924. const first_date = data?.[0]?.[0] || day_start_adjust
  925. for (
  926. let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
  927. day <= new Date();
  928. day.setDate(day.getDate() + 1)
  929. ) {
  930. streaks[day.toDateString()] = 0
  931. zeros[day.toDateString()] = true
  932. }
  933. // For all dates where something was done, set streak to 1
  934. for (let [date] of data)
  935. if (new Date(date) > new Date(settings.general.start_day))
  936. streaks[new Date(date - day_start_adjust).toDateString()] = 1
  937. // If user wants to count days where no lessons were available, set those streaks to 1 as well
  938. if (type === 'lessons' && settings.lessons.count_zeros) {
  939. // Delete dates where lessons were available
  940. for (let [started_at, id, level, unlocked_at] of data) {
  941. for (
  942. let day = new Date(unlocked_at - day_start_adjust);
  943. day <= new Date(started_at - day_start_adjust);
  944. day.setDate(day.getDate() + 1)
  945. ) {
  946. delete zeros[day.toDateString()]
  947. }
  948. }
  949. // Set all remaining dates to streak 1
  950. for (let date of Object.keys(zeros)) streaks[date] = 1
  951. }
  952. // Cumulate streaks
  953. let streak = 0
  954. for (
  955. let day = new Date(Math.max(first_date - day_start_adjust, new Date(settings.general.start_day).getTime()));
  956. day <= new Date().setHours(24);
  957. day.setDate(day.getDate() + 1)
  958. ) {
  959. if (streaks[day.toDateString()] === 1) streak++
  960. else streak = 0
  961. streaks[day.toDateString()] = streak
  962. }
  963. if (streaks[new Date().toDateString()] == 0)
  964. streaks[new Date().toDateString()] = streaks[new Date(new Date().setHours(-12)).toDateString()] || 0
  965. return streaks
  966. }
  967. // Get level up dates from API and lesson history
  968. async function get_level_ups(items) {
  969. let level_progressions = await wkof.Apiv2.get_endpoint('level_progressions')
  970. let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].data.unlocked_at
  971. // Find indefinite level ups by looking at lesson history
  972. let levels = {}
  973. // Sort lessons by level then unlocked date
  974. items.forEach((item) => {
  975. if (
  976. item.object !== 'kanji' ||
  977. !item.assignments ||
  978. !item.assignments.unlocked_at ||
  979. item.assignments.unlocked_at >= first_recorded_date
  980. )
  981. return
  982. let date = new Date(item.assignments.unlocked_at).toDateString()
  983. if (!levels[item.data.level]) levels[item.data.level] = {}
  984. if (!levels[item.data.level][date]) levels[item.data.level][date] = 1
  985. else levels[item.data.level][date]++
  986. })
  987. // Discard dates with less than 10 unlocked
  988. // then discard levels with no dates
  989. // then keep earliest date for each level
  990. for (let [level, data] of Object.entries(levels)) {
  991. for (let [date, count] of Object.entries(data)) {
  992. if (count < 10) delete data[date]
  993. }
  994. if (Object.keys(levels[level]).length == 0) {
  995. delete levels[level]
  996. continue
  997. }
  998. levels[level] = Object.keys(data).reduce((low, curr) => (low < curr ? low : curr), Date.now())
  999. }
  1000. // Map to array of [[level0, date0], [level1, date1], ...] Format
  1001. levels = Object.entries(levels).map(([level, date]) => [Number(level), date])
  1002. // Add definite level ups from API
  1003. Object.values(level_progressions).forEach((level) =>
  1004. levels.push([level.data.level, new Date(level.data.unlocked_at).toDateString()]),
  1005. )
  1006. return levels
  1007. }
  1008. /*-------------------------------------------------------------------------------------------------------------------------------*/
  1009. // Create and install the heatmap
  1010. async function install_heatmap(reviews, forecast, lessons, stats, items) {
  1011. let settings = wkof.settings[script_id]
  1012. // Create elements
  1013. let heatmap =
  1014. document.getElementById('heatmap') ||
  1015. create_elem({
  1016. type: 'section',
  1017. id: 'heatmap',
  1018. class: 'heatmap ' + (settings.other.visible_map === 'reviews' ? 'reviews' : ''),
  1019. position: settings.general.position,
  1020. onclick: day_click({ reviews, forecast, lessons }),
  1021. })
  1022. let buttons = create_buttons()
  1023. let views = create_elem({ type: 'div', class: 'views' })
  1024. heatmap.onmousedown = heatmap.onmouseup = heatmap.onmouseover = click_and_drag({ reviews, forecast, lessons })
  1025. heatmap.setAttribute('theme', settings.general.theme)
  1026. heatmap.style.setProperty(
  1027. '--color-now',
  1028. settings.general.now_indicator ? settings.general.color_now_indicator : 'transparent',
  1029. )
  1030. heatmap.style.setProperty(
  1031. '--color-level',
  1032. settings.general.level_indicator ? settings.general.color_level_indicator : 'transparent',
  1033. )
  1034. // Create heatmaps
  1035. let cooked_reviews = cook_data('reviews', reviews)
  1036. let cooked_lessons = cook_data('lessons', lessons)
  1037. let level_ups = await get_level_ups(items)
  1038. let reviews_view = create_view(
  1039. 'reviews',
  1040. stats,
  1041. level_ups,
  1042. reviews?.[0]?.[0] || Date.now(),
  1043. forecast.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
  1044. cooked_reviews.concat(forecast),
  1045. )
  1046. let lessons_view = create_view(
  1047. 'lessons',
  1048. stats,
  1049. level_ups,
  1050. lessons?.[0]?.[0] || Date.now(),
  1051. lessons.reduce((max, a) => (max > a[0] ? max : a[0]), 0),
  1052. cooked_lessons,
  1053. )
  1054. let popper = create_popper({ reviews: cooked_reviews, forecast, lessons: cooked_lessons })
  1055. views.append(reviews_view, lessons_view, popper)
  1056. // Install
  1057. heatmap.innerHTML = ''
  1058. heatmap.append(buttons, views)
  1059. let position = [
  1060. ['.dashboard__content', 'beforebegin'],
  1061. ['.dashboard__srs-progress', 'afterbegin'],
  1062. ['.srs-progress', 'afterend'],
  1063. ['.dashboard__item-lists', 'beforeend'],
  1064. ['.dashboard__content', 'afterend'],
  1065. ][settings.general.position]
  1066. if (!document.getElementById('heatmap') || heatmap.getAttribute('position') != settings.general.position)
  1067. document.querySelector(position[0]).insertAdjacentElement(position[1], heatmap)
  1068. heatmap.setAttribute('position', settings.general.position)
  1069. // Fire event to let people know it's finished loading
  1070. fire_event('heatmap-loaded', heatmap)
  1071. }
  1072. // Creates the buttons at the top of the heatmap
  1073. function create_buttons() {
  1074. let buttons = create_elem({ type: 'div', class: 'buttons' })
  1075. add_transitions(buttons)
  1076. const leftButtons = create_elem({ type: 'div', class: 'left' })
  1077. let settings_button = create_elem({
  1078. type: 'button',
  1079. class: 'settings-button hover-wrapper-target button',
  1080. 'aria-label': 'Settings',
  1081. children: [
  1082. create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Settings' }),
  1083. Icons.customIcon('settings'),
  1084. ],
  1085. onclick: open_settings,
  1086. })
  1087. let helpButton = create_elem({
  1088. type: 'a',
  1089. class: 'help-button hover-wrapper-target button',
  1090. 'aria-label': 'Settings',
  1091. href: 'https://community.wanikani.com/t/userscript-wanikani-heatmap',
  1092. target: '_blank',
  1093. children: [
  1094. create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Help' }),
  1095. Icons.customIcon('circle-question'),
  1096. ],
  1097. })
  1098. let infoButton = create_elem({
  1099. type: 'a',
  1100. class: 'info-button hover-wrapper-target button',
  1101. 'aria-label': 'Settings',
  1102. href: 'https://community.wanikani.com/t/api-changes-get-all-reviews/61617',
  1103. target: '_blank',
  1104. children: [
  1105. create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Why you might be missing reviews' }),
  1106. Icons.customIcon('warning'),
  1107. ],
  1108. })
  1109. leftButtons.append(settings_button, helpButton, infoButton)
  1110. let toggle_button = create_elem({
  1111. type: 'button',
  1112. class: 'toggle-button hover-wrapper-target button',
  1113. 'aria-label': 'Toggle between reviews and lessons',
  1114. children: [
  1115. create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Toggle view' }),
  1116. Icons.customIcon('inbox'),
  1117. ],
  1118. onclick: toggle_visible_map,
  1119. })
  1120. buttons.append(leftButtons, toggle_button)
  1121. return buttons
  1122. }
  1123. // Prepares data for the heatmap
  1124. function cook_data(type, data) {
  1125. if (type === 'reviews') {
  1126. let ans = (srs, err) => {
  1127. let srs2 = srs - Math.ceil(err / 2) * (srs < 5 ? 1 : 2) + (err == 0 ? 1 : 0)
  1128. return srs2 < 1 ? 1 : srs2
  1129. }
  1130. return data.map((item) => {
  1131. let cooked = [
  1132. item[0],
  1133. { reviews: 1, pass: item[3] + item[4] == 0 ? 1 : 0, incorrect: item[3] + item[4], streak: item[5] },
  1134. { 'reviews-ids': item[1] },
  1135. ]
  1136. cooked[1][type + '-srs1-' + item[2]] = 1
  1137. cooked[1][type + '-srs2-' + ans(item[2], item[3] + item[4])] = 1
  1138. return cooked
  1139. })
  1140. } else if (type === 'lessons')
  1141. return data.map((item) => [item[0], { lessons: 1, streak: item[4] }, { 'lessons-ids': item[1] }])
  1142. else if (type === 'forecast') return data
  1143. }
  1144. // Create heatmaps and peripherals such as stats
  1145. function create_view(type, stats, level_ups, first_date, last_date, data) {
  1146. let settings = wkof.settings[script_id]
  1147. let level_marks = level_ups.map(([level, date]) => [date, 'level-up' + (level == 60 ? ' level-60' : '')])
  1148. // New heatmap instance
  1149. let heatmap = new Heatmap(
  1150. {
  1151. type: 'year',
  1152. id: type,
  1153. week_start: settings.general.week_start,
  1154. day_start: settings.general.day_start,
  1155. first_date:
  1156. Math.max(new Date(settings.general.start_day).getTime(), first_date) -
  1157. settings.general.day_start * msh,
  1158. last_date: last_date,
  1159. segment_years: settings.general.segment_years,
  1160. zero_gap: settings.general.zero_gap,
  1161. markings: [[new Date(Date.now() - msh * settings.general.day_start), 'today'], ...level_marks],
  1162. day_labels: settings.general.day_labels === 'kanji' && ['月', '火', '水', '木', '金', '土', '日'],
  1163. day_hover_callback: (date, day_data) => {
  1164. let type2 = type
  1165. let time = new Date(date[0], date[1] - 1, date[2], 0, 0).getTime()
  1166. if (
  1167. type2 === 'reviews' &&
  1168. time > Date.now() - msh * settings.general.day_start &&
  1169. day_data.counts.forecast
  1170. )
  1171. type2 = 'forecast'
  1172. let string = `${(day_data.counts[type2] || 0).toLocaleString()} ${
  1173. type2 === 'forecast'
  1174. ? 'reviews upcoming'
  1175. : day_data.counts[type2] === 1
  1176. ? type2.slice(0, -1)
  1177. : type2
  1178. } on ${
  1179. new Date(time).toDateString().replace(/... /, '') + ' ' + kanji_day(new Date(time).getDay())
  1180. }`
  1181. if (time >= new Date(settings.general.start_day).getTime() && time > first_date) {
  1182. string += `\nDay ${(
  1183. Math.round(
  1184. (time -
  1185. Date.parse(
  1186. new Date(
  1187. Math.max(data[0]?.[0] || 0, new Date(settings.general.start_day).getTime()),
  1188. ).toDateString(),
  1189. )) /
  1190. msd,
  1191. ) + 1
  1192. ).toLocaleString()}`
  1193. }
  1194. if (
  1195. time < Date.now() &&
  1196. time >= new Date(settings.general.start_day).getTime() &&
  1197. time > first_date
  1198. )
  1199. string += `, Streak ${stats[type].streaks[new Date(time).toDateString()] || 0}`
  1200. string += '\n'
  1201. if (
  1202. type2 === 'reviews' &&
  1203. day_data.counts.forecast &&
  1204. new Date(time).toDateString() == new Date().toDateString()
  1205. ) {
  1206. string += `\n${day_data.counts.forecast} more reviews upcoming`
  1207. }
  1208. if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
  1209. string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
  1210. let level = (level_ups.find((a) => a[1] == new Date(time).toDateString()) || [undefined])[0]
  1211. if (level) string += '\nYou reached level ' + level + '!'
  1212. if (wkof.settings[script_id].other.times_popped < 5 && Object.keys(day_data.counts).length !== 0)
  1213. string += '\nClick for details!'
  1214. if (
  1215. wkof.settings[script_id].other.times_popped >= 5 &&
  1216. wkof.settings[script_id].other.times_dragged < 3 &&
  1217. Object.keys(day_data.counts).length !== 0
  1218. )
  1219. string += '\nDid you know that you can click and drag, too?'
  1220. return [string]
  1221. },
  1222. color_callback: (date, day_data) => color_picker(type, date, day_data),
  1223. },
  1224. data,
  1225. )
  1226. modify_heatmap(type, heatmap)
  1227. // Create layout
  1228. let view = create_elem({ type: 'div', class: type + ' view' })
  1229. let title = create_elem({ type: 'div', class: 'title', child: type.toProper() })
  1230. let [head_stats, foot_stats] = create_stats_elements(type, stats[type])
  1231. let years = create_elem({ type: 'div', class: 'years' + (settings.general.reverse_years ? ' reverse' : '') })
  1232. if (Math.max(...Object.keys(heatmap.maps)) > new Date().getFullYear()) {
  1233. if (settings.other.visible_years[type][new Date().getFullYear() + 1] !== false)
  1234. years.classList.add('visible-future')
  1235. years.classList.add('has-future')
  1236. }
  1237. years.setAttribute('month-labels', settings.general.month_labels)
  1238. years.setAttribute('day-labels', settings.general.day_labels)
  1239. for (let year of Object.values(heatmap.maps).reverse()) years.prepend(year)
  1240. view.append(title, head_stats, years, foot_stats)
  1241. return view
  1242. }
  1243. // Make changes to the heatmap object before it is displayed
  1244. function modify_heatmap(type, heatmap) {
  1245. for (let [year, map] of Object.entries(heatmap.maps)) {
  1246. let target = map.querySelector('.year-labels')
  1247. let up = create_elem({
  1248. type: 'div',
  1249. class: 'toggle-year up hover-wrapper-target',
  1250. onclick: toggle_year,
  1251. children: [
  1252. create_elem({
  1253. type: 'div',
  1254. class: 'hover-wrapper above',
  1255. child: create_elem({
  1256. type: 'div',
  1257. child:
  1258. 'Click to ' + (year == new Date().getFullYear() ? 'show next' : 'hide this') + ' year',
  1259. }),
  1260. }),
  1261. Icons.customIcon('chevron-up'),
  1262. ],
  1263. })
  1264. let down = create_elem({
  1265. type: 'div',
  1266. class: 'toggle-year down hover-wrapper-target',
  1267. onclick: toggle_year,
  1268. children: [
  1269. create_elem({
  1270. type: 'div',
  1271. class: 'hover-wrapper below',
  1272. child: create_elem({
  1273. type: 'div',
  1274. child:
  1275. 'Click to ' +
  1276. (year <= new Date().getFullYear() ? 'show previous' : 'hide this') +
  1277. ' year',
  1278. }),
  1279. }),
  1280. Icons.customIcon('chevron-down'),
  1281. ],
  1282. })
  1283. target.append(up, down)
  1284. if (wkof.settings[script_id].other.visible_years[type][year] === false) map.classList.add('hidden')
  1285. }
  1286. }
  1287. // Create the header and footer stats for a view
  1288. function create_stats_elements(type, stats) {
  1289. // Create an single stat element complete with hover info
  1290. let create_stat_element = (label, value, hover) => {
  1291. return create_elem({
  1292. type: 'div',
  1293. class: 'stat hover-wrapper-target',
  1294. children: [
  1295. create_elem({ type: 'div', class: 'hover-wrapper above', child: hover }),
  1296. create_elem({ type: 'span', class: 'stat-label', child: label }),
  1297. create_elem({ type: 'span', class: 'value', child: value }),
  1298. ],
  1299. })
  1300. }
  1301. // Create the elements
  1302. let head_stats = create_elem({
  1303. type: 'div',
  1304. class: 'head-stats stats',
  1305. children: [
  1306. create_stat_element(
  1307. 'Days Studied',
  1308. stats.days_studied[1] + '%',
  1309. stats.days_studied[0].toLocaleString() + ' out of ' + stats.days.toLocaleString(),
  1310. ),
  1311. create_stat_element(
  1312. 'Done Daily',
  1313. stats.average[0] + ' / ' + (stats.average[1] || 0),
  1314. 'Per Day / Days studied\nMax: ' + stats.max_done[0].toLocaleString() + ' on ' + stats.max_done[1],
  1315. ),
  1316. create_stat_element('Streak', stats.streak[1] + ' / ' + stats.streak[0], 'Current / Longest'),
  1317. ],
  1318. })
  1319. let foot_stats = create_elem({
  1320. type: 'div',
  1321. class: 'foot-stats stats',
  1322. children: [
  1323. create_stat_element(
  1324. 'Sessions',
  1325. stats.sessions.toLocaleString(),
  1326. (Math.floor(stats.total[0] / stats.sessions) || 0) + ' per session',
  1327. ),
  1328. create_stat_element(
  1329. type.toProper(),
  1330. stats.total[0].toLocaleString(),
  1331. create_table('left', [
  1332. ['Year', stats.total[1].toLocaleString()],
  1333. ['Month', stats.total[2].toLocaleString()],
  1334. ['Week', stats.total[3].toLocaleString()],
  1335. ['24h', stats.total[4].toLocaleString()],
  1336. ]),
  1337. ),
  1338. create_stat_element(
  1339. 'Time',
  1340. m_to_hm(stats.time[0]),
  1341. create_table('left', [
  1342. ['Year', m_to_hm(stats.time[1])],
  1343. ['Month', m_to_hm(stats.time[2])],
  1344. ['Week', m_to_hm(stats.time[3])],
  1345. ['24h', m_to_hm(stats.time[4])],
  1346. ]),
  1347. ),
  1348. ],
  1349. })
  1350. add_transitions(head_stats)
  1351. add_transitions(foot_stats)
  1352. return [head_stats, foot_stats]
  1353. }
  1354. // Add hover transition
  1355. function add_transitions(elem) {
  1356. elem.addEventListener('mouseover', (event) => {
  1357. const elem = event.target.closest('.hover-wrapper-target')
  1358. if (!elem) return
  1359. elem.classList.add('heatmap-transition')
  1360. setTimeout((_) => elem.classList.remove('heatmap-transition'), 20)
  1361. })
  1362. }
  1363. // Initiates the popper element
  1364. function create_popper(data) {
  1365. // Create layout
  1366. let popper = create_elem({ type: 'div', id: 'popper' })
  1367. let header = create_elem({ type: 'div', class: 'header' })
  1368. let minimap = create_elem({
  1369. type: 'div',
  1370. class: 'minimap',
  1371. children: [
  1372. create_elem({ type: 'span', class: 'minimap-label', child: 'Hours minimap' }),
  1373. create_elem({ type: 'div', class: 'hours-map' }),
  1374. ],
  1375. })
  1376. let stats = create_elem({ type: 'div', class: 'stats' })
  1377. let items = create_elem({ type: 'div', class: 'items' })
  1378. popper.append(header, minimap, stats, items)
  1379. document.addEventListener('click', (event) => {
  1380. if (!event.composedPath().find((a) => a === popper || (a.classList && a.classList.contains('years'))))
  1381. popper.classList.remove('popped')
  1382. })
  1383. // Create header
  1384. header.append(
  1385. create_elem({
  1386. type: 'div',
  1387. class: 'clear hover-wrapper-target',
  1388. children: [
  1389. create_elem({
  1390. type: 'div',
  1391. class: 'hover-wrapper above',
  1392. child: 'Clear all reviews from this day',
  1393. }),
  1394. create_elem({ type: 'button', id: 'clear_reviews', child: Icons.customIcon('trash') }),
  1395. ],
  1396. }),
  1397. create_elem({ type: 'div', class: 'date' }),
  1398. create_elem({
  1399. type: 'div',
  1400. class: 'subheader',
  1401. children: [create_elem({ type: 'span', class: 'count' }), create_elem({ type: 'span', class: 'time' })],
  1402. }),
  1403. create_elem({
  1404. type: 'div',
  1405. class: 'score hover-wrapper-target',
  1406. children: [
  1407. create_elem({ type: 'div', class: 'hover-wrapper above', child: 'Net progress of SRS levels' }),
  1408. create_elem({ type: 'span' }),
  1409. ],
  1410. }),
  1411. )
  1412. header.querySelector('#clear_reviews').addEventListener('click', async () => {
  1413. let [start, end] = header
  1414. .querySelector('.date')
  1415. .textContent.split('-')
  1416. .map((d) => new Date(d.replace(/\s*.\s*$/, '')))
  1417. if (!end) end = start
  1418. end = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).getTime() // Include end of interval
  1419. const reviews = await review_cache.get_reviews()
  1420. const newReviews = reviews.filter((review) => review[0] < start || review[0] >= end) // Omit reviews
  1421. await review_cache.reload() // Since API returns empty array this clears cache
  1422. await review_cache.insert(newReviews)
  1423. })
  1424. // Create minimap and stats
  1425. stats.append(
  1426. create_table(
  1427. 'left',
  1428. [['Levels'], [' 1-10', 0], ['11-20', 0], ['21-30', 0], ['31-40', 0], ['41-50', 0], ['51-60', 0]],
  1429. { class: 'levels' },
  1430. true,
  1431. ),
  1432. create_table(
  1433. 'left',
  1434. [
  1435. ['SRS'],
  1436. ['Before / After'],
  1437. ['App', 0, 0],
  1438. ['Gur', 0, 0],
  1439. ['Mas', 0, 0],
  1440. ['Enl', 0, 0],
  1441. ['Bur', 0, 0],
  1442. ],
  1443. {
  1444. class: 'srs hover-wrapper-target',
  1445. child: create_elem({
  1446. type: 'div',
  1447. class: 'hover-wrapper below',
  1448. child: create_elem({ type: 'table' }),
  1449. }),
  1450. },
  1451. ),
  1452. create_table('left', [['Type'], ['Rad', 0], ['Kan', 0], ['Voc', 0]], { class: 'type' }),
  1453. create_table('left', [['Summary'], ['Pass', 0], ['Fail', 0], ['Acc', 0]], { class: 'summary' }),
  1454. create_table('left', [['Answers'], ['Right', 0], ['Wrong', 0], ['Acc', 0]], {
  1455. class: 'answers hover-wrapper-target',
  1456. child: create_elem({
  1457. type: 'div',
  1458. class: 'hover-wrapper above',
  1459. child: 'The total number of correct and incorrect answers',
  1460. }),
  1461. }),
  1462. )
  1463. return popper
  1464. }
  1465. // Creates a new minimap for the popper
  1466. function create_minimap(type, data) {
  1467. let settings = wkof.settings[script_id]
  1468. let multiplier = 2
  1469. return new Heatmap(
  1470. {
  1471. type: 'day',
  1472. id: 'hours-map',
  1473. first_date: Date.parse(new Date(data[0][0] - settings.general.day_start * msh).toDateString()),
  1474. last_date: Date.parse(new Date(data[0][0] + msd - settings.general.day_start * msh).toDateString()),
  1475. day_start: settings.general.day_start,
  1476. day_hover_callback: (date, day_data) => {
  1477. let type2 = type
  1478. if (type2 === 'reviews' && Date.parse(date.join('-')) > Date.now() && day_data.counts.forecast)
  1479. type2 = 'forecast'
  1480. let string = [
  1481. `${(day_data.counts[type2] || 0).toLocaleString()} ${
  1482. type2 === 'forecast'
  1483. ? 'reviews upcoming'
  1484. : day_data.counts[type2] === 1
  1485. ? type2.slice(0, -1)
  1486. : type2
  1487. } at ${date[3]}:00`,
  1488. ]
  1489. if (type2 !== 'lessons' && day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')])
  1490. string += '\nBurns ' + day_data.counts[type2 + '-srs' + (type2 === 'reviews' ? '2-9' : '1-8')]
  1491. return string
  1492. },
  1493. color_callback: (date, day_data) => color_picker(type, date, day_data, 2),
  1494. },
  1495. data,
  1496. )
  1497. }
  1498. /*-------------------------------------------------------------------------------------------------------------------------------*/
  1499. // Automatically determines what the user's interval bounds should be using quantiles
  1500. function auto_range(stats, forecast_items) {
  1501. let settings = wkof.settings[script_id]
  1502. // Forecast needs to have some calculations done
  1503. let forecast_days = {}
  1504. for (let [date] of Object.values(forecast_items)) {
  1505. let string = new Date(date).toDateString()
  1506. if (!forecast_days[string]) forecast_days[string] = 1
  1507. else forecast_days[string]++
  1508. }
  1509. let forecast_mean = forecast_items.length / Object.keys(forecast_days).length
  1510. let forecast_sd =
  1511. Math.sqrt(
  1512. (1 / (forecast_items.length / forecast_mean)) *
  1513. Object.values(forecast_days)
  1514. .map((x) => Math.pow(x - forecast_mean, 2))
  1515. .reduce((a, b) => a + b, 0),
  1516. ) || 1
  1517. // Get intervals
  1518. let range = (length, gradient, mean, sd) => [
  1519. 1,
  1520. ...Array((length < 2 ? 2 : length) - 2)
  1521. .fill(null)
  1522. .map(
  1523. (_, i) =>
  1524. Math.round(ifcdf(((gradient ? 0.9 : 1) * (i + 1)) / (length - (gradient ? 1 : 0)), mean, sd)) ||
  1525. 1,
  1526. ),
  1527. ]
  1528. let reviews = range(
  1529. settings.reviews.colors.length,
  1530. settings.reviews.gradient,
  1531. stats.reviews.average[1],
  1532. stats.reviews.average[2],
  1533. )
  1534. let lessons = range(
  1535. settings.lessons.colors.length,
  1536. settings.lessons.gradient,
  1537. stats.lessons.average[1],
  1538. stats.lessons.average[2],
  1539. )
  1540. let forecast = range(settings.forecast.colors.length, settings.forecast.gradient, forecast_mean, forecast_sd)
  1541. if (settings.reviews.auto_range)
  1542. for (let i = 1; i < settings.reviews.colors.length; i++) settings.reviews.colors[i][0] = reviews[i - 1]
  1543. if (settings.lessons.auto_range)
  1544. for (let i = 1; i < settings.lessons.colors.length; i++) settings.lessons.colors[i][0] = lessons[i - 1]
  1545. if (settings.forecast.auto_range)
  1546. for (let i = 1; i < settings.forecast.colors.length; i++) settings.forecast.colors[i][0] = forecast[i - 1]
  1547. wkof.Settings.save(script_id)
  1548. }
  1549. // Picks colors for the heatmap days
  1550. function color_picker(type, date, day_data, multiplier = 1) {
  1551. let settings = wkof.settings[script_id]
  1552. let type2 = type
  1553. if (
  1554. type2 === 'reviews' &&
  1555. new Date(date[0], date[1] - 1, date[2], 0, 0).getTime() > Date.now() - msh * settings.general.day_start &&
  1556. day_data.counts.forecast
  1557. )
  1558. type2 = 'forecast'
  1559. let colors = settings[type2].colors
  1560. // If gradients are not enabled, use intervals
  1561. if (!settings[type2].gradient) {
  1562. for (let [bound, color] of colors.slice().reverse()) {
  1563. if (day_data.counts[type2] * multiplier >= bound) {
  1564. return color
  1565. }
  1566. }
  1567. return colors[0][1]
  1568. // If gradients are enabled, interpolate colors
  1569. } else {
  1570. // Multiplier is used for minimap to get better ranges
  1571. if (!day_data.counts[type2] * multiplier) return colors[0][1]
  1572. if (day_data.counts[type2] * multiplier >= colors[colors.length - 1][0]) return colors[colors.length - 1][1]
  1573. for (let i = 2; i < colors.length; i++) {
  1574. if (day_data.counts[type2] * multiplier <= colors[i][0]) {
  1575. let percentage =
  1576. (day_data.counts[type2] * multiplier - colors[i - 1][0]) / (colors[i][0] - colors[i - 1][0])
  1577. return interpolate_color(colors[i - 1][1], colors[i][1], percentage)
  1578. }
  1579. }
  1580. }
  1581. }
  1582. // Toggles between lessons and reviews
  1583. function toggle_visible_map() {
  1584. let heatmap = document.getElementById('heatmap')
  1585. heatmap.classList.toggle('reviews')
  1586. wkof.settings[script_id].other.visible_map = heatmap.classList.contains('reviews') ? 'reviews' : 'lessons'
  1587. wkof.Settings.save(script_id)
  1588. }
  1589. // Toggles the visibility of the years
  1590. function toggle_year(event) {
  1591. let visible_years = wkof.settings[script_id].other.visible_years
  1592. let year_elem = event.target.closest('.year')
  1593. let up = event.target.closest('.toggle-year').classList.contains('up')
  1594. let year = Number(year_elem.getAttribute('data-year'))
  1595. let future = year > new Date().getFullYear()
  1596. let type = year_elem.classList.contains('reviews') ? 'reviews' : 'lessons'
  1597. if (up || (!up && future)) {
  1598. if (year == new Date().getFullYear()) {
  1599. visible_years[type][year + 1] = true
  1600. year_elem.nextElementSibling.classList.remove('hidden')
  1601. year_elem.parentElement.classList.add('visible-future')
  1602. } else {
  1603. visible_years[type][year] = false
  1604. year_elem.classList.add('hidden')
  1605. if (!up && future) year_elem.parentElement.classList.remove('visible-future')
  1606. }
  1607. } else {
  1608. visible_years[type][year - 1] = true
  1609. year_elem.previousElementSibling.classList.remove('hidden')
  1610. }
  1611. // Make sure at least one year is visible
  1612. if (!Object.values(visible_years[type]).find((a) => a == true)) {
  1613. visible_years[type][year] = true
  1614. }
  1615. wkof.Settings.save(script_id)
  1616. }
  1617. // Updates the popper with new info
  1618. async function update_popper(event, type, title, info, minimap_data, burns, time) {
  1619. let items_id = await wkof.ItemData.get_index(await wkof.ItemData.get_items('include_hidden'), 'subject_id')
  1620. let popper = document.getElementById('popper')
  1621. // Get info
  1622. let levels = new Array(61).fill(0)
  1623. levels[0] = new Array(6).fill(0)
  1624. let item_types = { rad: 0, kan: 0, voc: 0 }
  1625. for (let id of info.lists[type + '-ids']) {
  1626. let item = items_id[id]
  1627. if (!item) continue
  1628. levels[0][Math.floor((item.data.level - 1) / 10)]++
  1629. levels[item.data.level]++
  1630. const type = item.object === 'kana_vocabulary' ? 'voc' : item.object.slice(0, 3)
  1631. item_types[type]++
  1632. }
  1633. let srs = new Array(10).fill(null).map((_) => [0, 0])
  1634. for (let i = 1; i < 10; i++) {
  1635. srs[i][0] = info.counts[type + '-srs1-' + i] || 0
  1636. srs[i][1] = info.counts[type + '-srs2-' + i] || 0
  1637. }
  1638. let srs_counter = (index, start, end) =>
  1639. srs.map((a, i) => (i >= start ? (i <= end ? a[index] : 0) : 0)).reduce((a, b) => a + b, 0)
  1640. srs[0] = [
  1641. [srs_counter(0, 1, 4), srs_counter(1, 1, 4)],
  1642. [srs_counter(0, 5, 6), srs_counter(1, 5, 6)],
  1643. srs[7],
  1644. srs[8],
  1645. srs[9],
  1646. ]
  1647. let srs_diff = Object.entries(srs.slice(1)).reduce((a, b) => a + b[0] * (b[1][1] - b[1][0]), 0)
  1648. let pass = [
  1649. info.counts.pass,
  1650. info.counts.reviews - info.counts.pass,
  1651. Math.floor((info.counts.pass / info.counts.reviews) * 100),
  1652. ]
  1653. let answers = [
  1654. info.counts.reviews * 2 - item_types.rad,
  1655. info.counts.incorrect,
  1656. Math.floor(
  1657. ((info.counts.reviews * 2 - item_types.rad) /
  1658. (info.counts.incorrect + info.counts.reviews * 2 - item_types.rad)) *
  1659. 100,
  1660. ),
  1661. ]
  1662. let item_elems = []
  1663. const ids = [...new Set(info.lists[type + '-ids'])]
  1664. const svgs = {}
  1665. const svgPromises = []
  1666. for (const id of ids) {
  1667. if (!items_id[id] || items_id[id]?.data?.characters) continue
  1668. svgPromises.push(
  1669. wkof
  1670. .load_file(
  1671. items_id[id].data.character_images.find(
  1672. (a) => a.content_type == 'image/svg+xml' && a.metadata.inline_styles,
  1673. ).url,
  1674. )
  1675. .then((svg) => {
  1676. let svgElem = document.createElement('span')
  1677. svgElem.innerHTML = svg.replace(/<svg /, `<svg class="radical-svg" `)
  1678. svgs[id] = svgElem.firstChild
  1679. }),
  1680. )
  1681. }
  1682. await Promise.allSettled(svgPromises)
  1683. for (let id of ids) {
  1684. let item = items_id[id]
  1685. if (!item) continue
  1686. let burn = burns.includes(id)
  1687. const type = item.object === 'kana_vocabulary' ? 'vocabulary' : item.object
  1688. item_elems.push(
  1689. create_elem({
  1690. type: 'a',
  1691. class: 'item ' + type + ' hover-wrapper-target' + (burn ? ' burn' : ''),
  1692. href: item.data.document_url,
  1693. children: [
  1694. create_elem({
  1695. type: 'div',
  1696. class: 'hover-wrapper above',
  1697. children: [
  1698. create_elem({
  1699. type: 'a',
  1700. class: 'characters',
  1701. href: item.data.document_url,
  1702. child: item.data.characters || svgs[id].cloneNode(true),
  1703. }),
  1704. create_table(
  1705. 'left',
  1706. [
  1707. ['Meanings', item.data.meanings.map((i) => i.meaning).join(', ')],
  1708. [
  1709. 'Readings',
  1710. item.data.readings
  1711. ? item.data.readings.map((i) => i.reading).join('、 ')
  1712. : '-',
  1713. ],
  1714. ['Level', item.data.level],
  1715. ],
  1716. { class: 'info' },
  1717. ),
  1718. ],
  1719. }),
  1720. create_elem({
  1721. type: 'a',
  1722. class: 'characters',
  1723. child: item.data.characters || svgs[id].cloneNode(true),
  1724. }),
  1725. ],
  1726. }),
  1727. )
  1728. }
  1729. let time_str = ms_to_hms(time)
  1730. let count = info.lists[type + '-ids'].length
  1731. let count_str =
  1732. (type === 'forecast' ? 'upcoming review' : type.slice(0, type.length - 1)) + (count === 1 ? '' : 's')
  1733. // Populate popper
  1734. popper.className = type
  1735. popper.querySelector('.date').innerText = title
  1736. popper.querySelector('.count').innerText = count.toLocaleString() + ' ' + count_str
  1737. popper.querySelector('.time').innerText = type == 'forecast' ? '' : time_str ? ' (' + time_str + ')' : ''
  1738. popper.querySelector('.score > span').innerText = (srs_diff < 0 ? '' : '+') + srs_diff.toLocaleString()
  1739. popper.querySelectorAll('.levels .hover-wrapper > *').forEach((e) => e.remove())
  1740. popper.querySelectorAll('.levels > tr > td').forEach((e, i) => {
  1741. e.innerText = levels[0][i].toLocaleString()
  1742. e.parentElement.setAttribute('data-count', levels[0][i])
  1743. e.parentElement.children[0].append(
  1744. create_table(
  1745. 'left',
  1746. levels
  1747. .slice(1)
  1748. .map((a, j) => [j + 1, a.toLocaleString()])
  1749. .filter((a) => Math.floor((a[0] - 1) / 10) == i && a[1] != 0),
  1750. ),
  1751. )
  1752. })
  1753. popper.querySelectorAll('.srs > tr > td').forEach((e, i) => {
  1754. e.innerText = srs[0][Math.floor(i / 2)][i % 2].toLocaleString()
  1755. })
  1756. popper
  1757. .querySelector('.srs .hover-wrapper table')
  1758. .replaceWith(
  1759. create_table('left', [
  1760. ['SRS'],
  1761. ['Before / After'],
  1762. ...srs
  1763. .slice(1)
  1764. .map((a, i) => [
  1765. ['App 1', 'App 2', 'App 3', 'App 4', 'Gur 1', 'Gur 2', 'Mas', 'Enl', 'Bur'][i],
  1766. ...a.map((_) => _.toLocaleString()),
  1767. ]),
  1768. ]),
  1769. )
  1770. popper.querySelectorAll('.type td').forEach((e, i) => {
  1771. e.innerText = item_types[['rad', 'kan', 'voc'][i]].toLocaleString()
  1772. })
  1773. popper.querySelectorAll('.summary td').forEach((e, i) => {
  1774. e.innerText = (pass[i] || 0).toLocaleString()
  1775. })
  1776. popper.querySelectorAll('.answers td').forEach((e, i) => {
  1777. e.innerText = (answers[i] || 0).toLocaleString()
  1778. })
  1779. popper.querySelector('.items').replaceWith(create_elem({ type: 'div', class: 'items', children: item_elems }))
  1780. popper.querySelector('.minimap > .hours-map').replaceWith(create_minimap(type, minimap_data).maps.day)
  1781. popper.style.top = event.pageY + 50 + 'px'
  1782. popper.classList.add('popped')
  1783. wkof.settings[script_id].other.times_popped++
  1784. wkof.Settings.save(script_id)
  1785. }
  1786. /*-------------------------------------------------------------------------------------------------------------------------------*/
  1787. // Returns the function that handles clicks on days. Wrapped for data storage
  1788. function day_click(data) {
  1789. function event_handler(event) {
  1790. let settings = wkof.settings[script_id]
  1791. let elem = event.target
  1792. if (elem.classList.contains('day')) {
  1793. let date = elem.getAttribute('data-date').split('-')
  1794. date = new Date(date[0], date[1] - 1, date[2], 0, 0)
  1795. let type = elem.closest('.view').classList.contains('reviews')
  1796. ? date < new Date()
  1797. ? 'reviews'
  1798. : 'forecast'
  1799. : 'lessons'
  1800. if (Object.keys(elem.info.lists).length) {
  1801. let title = `${date.toDateString().slice(4)} ${kanji_day(date.getDay())}`
  1802. let today = new Date(new Date().toDateString()).getTime()
  1803. let offset = wkof.settings[script_id].general.day_start * msh
  1804. let day_data = data[type].filter(
  1805. (a) => a[0] >= date.getTime() + offset && a[0] < date.getTime() + msd + offset,
  1806. )
  1807. let minimap_data = cook_data(type, day_data)
  1808. let burns = day_data
  1809. .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
  1810. .map((item) => item[1])
  1811. let time = minimap_data
  1812. .map((a, i) => a[0] - (minimap_data[i - 1] || [0])[0])
  1813. .filter((a) => a < settings.general.session_limit * 60 * 1000)
  1814. .reduce((a, b) => a + b, 0)
  1815. update_popper(event, type, title, elem.info, minimap_data, burns, time)
  1816. }
  1817. }
  1818. }
  1819. return event_handler
  1820. }
  1821. // Returns the function that handles click and drag. Wrapped for data storage
  1822. function click_and_drag(data) {
  1823. let down,
  1824. first_day,
  1825. first_date,
  1826. marked = []
  1827. function event_handler(event) {
  1828. let elem = event.target
  1829. // If event concerns a day element, proceed
  1830. if (elem.classList.contains('day')) {
  1831. let date = elem.getAttribute('data-date').split('-')
  1832. date = new Date(date[0], date[1] - 1, date[2], 0, 0)
  1833. let type = elem.closest('.view').classList.contains('reviews')
  1834. ? date < new Date()
  1835. ? 'reviews'
  1836. : 'forecast'
  1837. : 'lessons'
  1838. // Start selection
  1839. if (event.type === 'mousedown') {
  1840. event.preventDefault()
  1841. down = true
  1842. first_day = elem
  1843. first_date = new Date(elem.getAttribute('data-date'))
  1844. }
  1845. // End selection
  1846. if (event.type === 'mouseup') {
  1847. if (first_day !== elem) {
  1848. // Gather the data then update popper
  1849. let second_date = new Date(elem.getAttribute('data-date'))
  1850. let start_date = first_date < second_date ? first_date : second_date
  1851. let end_date = first_date < second_date ? second_date : first_date
  1852. type = elem.closest('.view').classList.contains('reviews')
  1853. ? start_date < new Date()
  1854. ? 'reviews'
  1855. : 'forecast'
  1856. : 'lessons'
  1857. let title = `${start_date.toDateString().slice(4)} ${kanji_day(
  1858. start_date.getDay(),
  1859. )} - ${end_date.toDateString().slice(4)} ${kanji_day(end_date.getDay())}`
  1860. let today = new Date(new Date().toDateString()).getTime()
  1861. let offset = wkof.settings[script_id].general.day_start * msh
  1862. let day_data = data[type].filter(
  1863. (a) => a[0] > start_date.getTime() + offset && a[0] < end_date.getTime() + msd + offset,
  1864. )
  1865. let mapped_day_data = day_data.map((a) => [
  1866. today + new Date(a[0]).getHours() * msh + wkof.settings[script_id].general.day_start * msh,
  1867. ...a.slice(1),
  1868. ])
  1869. let minimap_data = cook_data(type, mapped_day_data)
  1870. let popper_info = { counts: {}, lists: {} }
  1871. for (let item of minimap_data) {
  1872. for (let [key, value] of Object.entries(item[1])) {
  1873. if (!popper_info.counts[key]) popper_info.counts[key] = 0
  1874. popper_info.counts[key] += value
  1875. }
  1876. for (let [key, value] of Object.entries(item[2])) {
  1877. if (!popper_info.lists[key]) popper_info.lists[key] = []
  1878. popper_info.lists[key].push(value)
  1879. }
  1880. }
  1881. let burns = day_data
  1882. .filter((item) => item[2] === 8 && item[3] + item[4] === 0)
  1883. .map((item) => item[1])
  1884. let time = day_data
  1885. .map((a, i) => Math.floor((a[0] - (day_data[i - 1] || [0])[0]) / (60 * 1000)))
  1886. .filter((a) => a < 10)
  1887. .reduce((a, b) => a + b, 0)
  1888. update_popper(event, type, title, popper_info, minimap_data, burns, time)
  1889. wkof.settings[script_id].other.times_dragged++
  1890. }
  1891. }
  1892. // Update selection
  1893. if (event.type === 'mouseover' && down) {
  1894. let view = document.querySelector('#heatmap .view.' + (type === 'forecast' ? 'reviews' : type))
  1895. if (!view) return
  1896. for (let m of marked) {
  1897. m.classList.remove('selected')
  1898. }
  1899. marked = []
  1900. elem.classList.add('selected')
  1901. marked.push(elem)
  1902. let d = new Date(first_date.getTime())
  1903. while (d.toDateString() !== date.toDateString()) {
  1904. let e = view.querySelector(
  1905. `.day[data-date="${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}"]`,
  1906. )
  1907. e.classList.add('selected')
  1908. marked.push(e)
  1909. d.setDate(d.getDate() + (d < date ? 1 : -1))
  1910. }
  1911. }
  1912. }
  1913. // If mouse is let go, remove selection
  1914. if (event.type === 'mouseup') {
  1915. down = false
  1916. for (let m of marked) {
  1917. m.classList.remove('selected')
  1918. }
  1919. marked = []
  1920. }
  1921. }
  1922. return event_handler
  1923. }
  1924. /*-------------------------------------------------------------------------------------------------------------------------------*/
  1925. // Shorthand for creating new elements. Keys that do not have a special function will be added as attributes
  1926. function create_elem(config) {
  1927. let div = document.createElement(config.type)
  1928. for (let [attr, value] of Object.entries(config)) {
  1929. if (attr === 'type') continue
  1930. else if (attr === 'child') div.append(value)
  1931. else if (attr === 'children') div.append(...value)
  1932. else if (attr === 'value') div.value = value
  1933. else if (attr === 'input') div.setAttribute('type', value)
  1934. else if (attr === 'onclick') div.onclick = value
  1935. else if (attr === 'callback') continue
  1936. else div.setAttribute(attr, value)
  1937. }
  1938. if (config.callback) config.callback(div)
  1939. return div
  1940. }
  1941. // Creates a table from a matrix
  1942. function create_table(header, data, table_attr, tr_hover) {
  1943. let table = create_elem(Object.assign({ type: 'table' }, table_attr))
  1944. for (let [i, row] of Object.entries(data)) {
  1945. let tr_config = { type: 'tr' }
  1946. if (tr_hover) {
  1947. tr_config.class = 'hover-wrapper-target'
  1948. tr_config.child = create_elem({ type: 'div', class: 'hover-wrapper below' })
  1949. }
  1950. let tr = create_elem(tr_config)
  1951. for (let [j, cell] of Object.entries(row)) {
  1952. let cell_type = (header == 'top' && i == 0) || (header == 'left' && j == 0) ? 'th' : 'td'
  1953. tr.append(create_elem({ type: cell_type, child: cell }))
  1954. }
  1955. table.append(tr)
  1956. }
  1957. return table
  1958. }
  1959. // Returns the kanij for the day
  1960. function kanji_day(day) {
  1961. return ['日', '月', '火', '水', '木', '金', '土'][day]
  1962. }
  1963. // Converts minutes to a timestamp string "#h #m"
  1964. function m_to_hm(minutes) {
  1965. return Math.floor(minutes / 60) + 'h ' + Math.floor(minutes % 60) + 'm'
  1966. }
  1967. // Converts ms to a timestamp string "#h #m #s" where only the first two non-zero values are included
  1968. function ms_to_hms(ms) {
  1969. const hms = [
  1970. [ms + 1, msh, 'h'],
  1971. [msh, 60 * 1000, 'm'],
  1972. [60 * 1000, 1000, 's'],
  1973. ]
  1974. return hms
  1975. .map((a) => Math.floor((ms % a[0]) / a[1]) + a[2])
  1976. .filter((a) => a[0] !== '0')
  1977. .slice(0, 2)
  1978. .join(' ')
  1979. }
  1980. // Capitalizes the first character in a string. "proper" → "Proper"
  1981. String.prototype.toProper = function () {
  1982. return this.slice(0, 1).toUpperCase() + this.slice(1)
  1983. }
  1984. // Returns a hex color between the left and right hex colors
  1985. function interpolate_color(left, right, index) {
  1986. if (isNaN(index)) return left
  1987. left = hex_to_rgb(left)
  1988. right = hex_to_rgb(right)
  1989. let r###lt = [0, 0, 0]
  1990. for (let i = 0; i < 3; i++) r###lt[i] = Math.round(left[i] + index * (right[i] - left[i]))
  1991. return rgb_to_hex(r###lt)
  1992. }
  1993. // Converts a hex color to rgb
  1994. function hex_to_rgb(hex) {
  1995. let r###lt = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  1996. return [parseInt(r###lt[1], 16), parseInt(r###lt[2], 16), parseInt(r###lt[3], 16)]
  1997. }
  1998. // Converts an rgb color to hex
  1999. function rgb_to_hex(cols) {
  2000. let rgb = cols[2] | (cols[1] << 8) | (cols[0] << 16)
  2001. return '#' + (0x1000000 + rgb).toString(16).slice(1)
  2002. }
  2003. // Crude approximation of inverse folded cumulative distribution function
  2004. // Used for the quantiles in auto-ranging
  2005. function ifcdf(p, m, sd) {
  2006. // Folded cumulative distribution function
  2007. function fcdf(x, mean, sd) {
  2008. // Error function
  2009. function erf(x) {
  2010. let sign = x >= 0 ? 1 : -1
  2011. x = Math.abs(x)
  2012. let a1 = 0.254829592,
  2013. a2 = -0.284496736
  2014. let a3 = 1.421413741,
  2015. a4 = -1.453152027
  2016. let a5 = 1.061405429,
  2017. p = 0.3275911
  2018. let t = 1 / (1 + p * x)
  2019. let y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x)
  2020. return sign * y
  2021. }
  2022. return 0.5 * (erf((x + mean) / (sd * Math.sqrt(2))) + erf((x - mean) / (sd * Math.sqrt(2))))
  2023. }
  2024. let p2 = 0,
  2025. items = 0,
  2026. step = Math.ceil(sd / 10)
  2027. while (p2 < p) {
  2028. items += step
  2029. p2 = fcdf(items, m, sd)
  2030. }
  2031. return items
  2032. }
  2033. // Fires a custom event on an element
  2034. function fire_event(event_name, elem) {
  2035. const event = document.createEvent('Event')
  2036. event.initEvent(event_name, true, true)
  2037. elem.dispatchEvent(event)
  2038. }
  2039. })(window.wkof, window.review_cache, window.Heatmap, window.Icons)