Wanikani Level-Up Time Assistant

Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.

  1. // ==UserScript==
  2. // @name Wanikani Level-Up Time Assistant
  3. // @namespace https://greasyfork.org/en/users/11878
  4. // @version 1.5.1
  5. // @description Shows the earliest date and time you can level up if your reviews are correct. Adds an indication if you have items available for lesson/review that are needed to advance your current level.
  6. // @author Inserio
  7. // @match https://www.wanikani.com/*
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11. /* global wkof */
  12.  
  13. (function(lu_obj) {
  14. // ========================================================================
  15. // Initialization of the Wanikani Open Framework.
  16. // -------------------------------------------------------------------
  17.  
  18. const scriptName = 'Wanikani Level-Up Time Assistant', scriptId = scriptName.substring(9).toLowerCase().replaceAll(/[- ]/g, '_'),
  19. containerId = 'lu-container', dateId = 'lu-level-up-date', arrowId = 'lu-arrow-up', wkof_version_needed = '1.2.6';
  20. if (!window.wkof) {
  21. if (confirm(scriptName+' requires Wanikani Open Framework.\nDo you want to be forwarded to the installation instructions?'))
  22. window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
  23. return;
  24. }
  25. if (window.wkof.version.compare_to(wkof_version_needed) === 'older') {
  26. if (confirm(scriptName+' requires Wanikani Open Framework version '+wkof_version_needed+'.\nDo you want to be forwarded to the update page?'))
  27. window.location.href = 'https://greasyfork.org/en/scripts/38582-wanikani-open-framework';
  28. return;
  29. }
  30.  
  31. // ========================================================================
  32. // Globals
  33. // -------------------------------------------------------------------
  34.  
  35. // TODO: Perhaps make the logging option configurable in a settings menu?
  36. const config = {
  37. log: {
  38. enabled: false,
  39. detailed: true
  40. },
  41. loading: false,
  42. callback: null
  43. }, dashboard_url = /^\/(dashboard)?$/, srs_stages = ['Unlocked', 'Apprentice 1', 'Apprentice 2', 'Apprentice 3', 'Apprentice 4', 'Guru 1', 'Guru 2', 'Master', 'Enlighten', 'Burn'];
  44. let items_by_type, items_by_subject_id;
  45.  
  46. // ========================================================================
  47. // Startup
  48. // -------------------------------------------------------------------
  49. lu_obj.items_with_soonest_assignments = null;
  50. lu_obj.items_not_passed_with_assignments_available = null;
  51.  
  52. wkof.include('ItemData');
  53. wkof.on_pageload(dashboard_url, startup);
  54.  
  55. function startup() {
  56. install_css();
  57. init_ui();
  58. wkof.ready('ItemData').then(fetch_items);
  59. }
  60.  
  61. /**
  62. * Install stylesheet.
  63. */
  64. function install_css() {
  65. if (document.getElementById(scriptId+'-style')) return;
  66. const lu_css = `<style id="${scriptId}-style">#${containerId}{display:flex;align-items:center;justify-content:space-evenly;margin:0 5px 12px 5px;}
  67. #${containerId} #${arrowId}{height:20px;width:20px;padding:3px;font-size:14px;font-family:"Noto Sans JP", "Noto Sans SC", sans-serif;color:white;background-color:darkgray;cursor:default;border-radius:14px;}
  68. #${containerId} #${arrowId}.levelup-items{background-color:#00ff00;cursor:pointer;animation:lu-pulse 1s infinite;}
  69. .hidden {visibility:hidden;}
  70. @keyframes lu-pulse{
  71. 0%,100%{-ms-transform:scale(1);-o-transform:scale(1);-webkit-transform:scale(1);-moz-transform:scale(1);transform:scale(1);}
  72. 50%{-ms-transform:scale(1.25);-o-transform:scale(1.25);-webkit-transform:scale(1.25);-moz-transform:scale(1.25);transform:scale(1.25);}
  73. }</style>`;
  74. document.getElementsByTagName('head')[0].insertAdjacentHTML('beforeend',lu_css);
  75. }
  76.  
  77. /**
  78. * Initialize the user interface.
  79. */
  80. function init_ui() {
  81. if (document.getElementById(containerId) !== null) return;
  82. const lu_html = `<div id="${containerId}" class="hidden"><span id="${arrowId}" title="${get_text_for_icon_tooltip()}">&#x2B06;</span><strong>Earliest Level Up: </strong><span id="${dateId}"></span></div>`;
  83. document.querySelector('.dashboard__review-forecast > .wk-panel--review-forecast > :first-child').insertAdjacentHTML('beforebegin',lu_html);
  84. }
  85.  
  86. function prepare_radicals(items) {
  87. lu_obj.load_time = new Date();
  88. // Add "is_locked", "earliest_study_date", "current_earliest_study_date", and "earliest_guru_date" properties to the `scriptId` property of items
  89. // Need to parse radicals first so that locked kanji get the proper dates assigned to them
  90. add_dates_to_items(items);
  91. items_by_subject_id = get_items_by_index(items, 'id', 'single'); // store items for reference when parsing component_subject_ids
  92. }
  93.  
  94. function prepare_items(items) {
  95. items_by_type = get_items_by_index(items, 'object', 'array');
  96. Object.assign(items_by_subject_id, get_items_by_index(items, 'id', 'single')); // add kanji items to the items_by_subject_id object
  97.  
  98. // Add "is_locked", "earliest_study_date", "current_earliest_study_date", and "earliest_guru_date" properties to the `scriptId` property of items
  99. add_dates_to_items(items_by_type.kanji);
  100.  
  101. // Sort the items by the current_earliest_study_date, then the earliest_guru_date, then the id. This will determine how they appear in the console.
  102. lu_obj.items = items.sort(get_sort_method(`+${scriptId}.current_earliest_study_date`,`+${scriptId}.earliest_guru_date`,'+id'));
  103.  
  104. // Cache these filters for quick lookups
  105. lu_obj.items_not_locked_and_not_passed = Array.from(get_not_locked_but_not_passed_items(lu_obj.items));
  106. lu_obj.items_not_passed_with_assignments_available = Array.from(get_not_passed_items_with_available_assignments(lu_obj.items));
  107. lu_obj.items_with_soonest_assignments = get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed);
  108.  
  109. // Log the results to the console
  110. log_base_items_stats();
  111.  
  112. // Get the level up date and update the UI
  113. process_items();
  114.  
  115. // Setup a callback to fetch new data and re-run the UI updates when current level radicals/kanji become available
  116. setup_next_reviews_callback();
  117. }
  118.  
  119. // ========================================================================
  120. // Populate level info from API.
  121. // -------------------------------------------------------------------
  122. function fetch_items() {
  123. if (config.loading) return Promise.resolve();
  124. config.loading = true;
  125. // Fetch only radicals and kanji for current level.
  126. // Include /subjects and /assignments endpoints
  127. return wkof.ItemData.get_items({
  128. wk_items:{
  129. options:{ assignments:true },
  130. filters:{ level:'1..+0', item_type:'rad' }
  131. }
  132. }).then(prepare_radicals).then(() => wkof.ItemData.get_items({
  133. wk_items:{
  134. options:{ assignments:true },
  135. filters:{ level:'+0', item_type:'rad,kan' }
  136. }
  137. })).then(prepare_items).then(() => config.loading = false);
  138. }
  139.  
  140. function process_items() {
  141. lu_obj.level_up_date = get_level_up_date();
  142. let lu_container = document.getElementById(containerId);
  143. if (!lu_container)
  144. return;
  145. if (lu_container.classList.contains('hidden'))
  146. lu_container.classList.remove('hidden');
  147. update_ui();
  148. let lu_level_up_date = document.getElementById(dateId);
  149. let lu_arrow_up = document.getElementById(arrowId);
  150. if (lu_arrow_up) {
  151. lu_arrow_up.onmouseover = function(){update_ui(arrowId);};
  152. }
  153. if (lu_level_up_date) {
  154. lu_level_up_date.onmouseover = function(){update_ui(dateId);};
  155. lu_level_up_date.onclick = function(){config.log.enabled = true; fetch_items();};
  156. }
  157. }
  158.  
  159. function update_date_title() {
  160. let lu_level_up_date = document.getElementById(dateId);
  161. if (!lu_level_up_date) return;
  162. let dateOutput = format_date_to_standard_output(lu_obj.level_up_date, false);
  163. let wait_time = format_two_dates_diff_to_minimal_output(lu_obj.level_up_date, lu_obj.load_time, true);
  164. lu_level_up_date.innerHTML = dateOutput;
  165. lu_level_up_date.title = (wait_time==='Now' ? 'Available now' : wait_time)+'\nClick to update data and log results to the console';
  166. }
  167.  
  168. function update_arrow_title() {
  169. let lu_arrow_up = document.getElementById(arrowId);
  170. if (!lu_arrow_up) return;
  171. let title = '';
  172. let next_items = lu_obj.items_with_soonest_assignments;
  173. let item_count = next_items ? next_items.length : 0;
  174. if (item_count>0) {
  175. let review_time = format_date_to_standard_output(next_items[0][scriptId].current_earliest_study_date, true, true);
  176. if (review_time === 'Now') {
  177. title = `${item_count} item${item_count===1 ? ' needed to pass this level is' : 's needed to pass this level are'}`+
  178. ' currently available to study\nClick here to proceed to a new session';
  179. next_items = unique_from(get_next_soonest_study_items(lu_obj.items_not_locked_and_not_passed, next_items));
  180. item_count = next_items.length;
  181. if (item_count>0) {
  182. title += '\n\n';
  183. review_time = format_date_to_standard_output(next_items[0][scriptId].current_earliest_study_date, true, true);
  184. }
  185. }
  186. if (item_count>0) {
  187. title += `The next ${item_count} item${item_count>1 ? 's' : ''} of the ones needed to pass this level will arrive at:\n${(review_time)}`;
  188. }
  189. } else {
  190. title = get_text_for_icon_tooltip();
  191. }
  192. lu_arrow_up.title = title;
  193. if (lu_obj.items_not_passed_with_assignments_available && lu_obj.items_not_passed_with_assignments_available.length>0) {
  194. let destination = lu_obj.items_not_passed_with_assignments_available.find(a=>!a.assignments.available_at) ? 'lesson' : 'review';
  195. lu_arrow_up.onclick = function(){window.location='https://www.wanikani.com/subjects/'+destination;};
  196. lu_arrow_up.classList.add('levelup-items');
  197. } else {
  198. lu_arrow_up.onclick = null;
  199. lu_arrow_up.classList.remove('levelup-items');
  200. }
  201. }
  202.  
  203. function update_ui(element_id) {
  204. lu_obj.load_time = new Date();
  205. if (!element_id || element_id === dateId)
  206. update_date_title();
  207. if (!element_id || element_id === arrowId)
  208. update_arrow_title();
  209. }
  210.  
  211. function setup_next_reviews_callback() {
  212. if (config.callback) {
  213. clearTimeout(config.callback);
  214. config.callback = null;
  215. }
  216. if (!lu_obj.items_with_soonest_assignments || lu_obj.items_with_soonest_assignments.length === 0) return;
  217. let time_diff = lu_obj.items_with_soonest_assignments[0][scriptId].current_earliest_study_date.getTime() - (new Date()).getTime();
  218. if (time_diff <= 0) return;
  219. config.callback = setTimeout(function() {
  220. config.callback = null;
  221. let log_enabled = config.log.enabled;
  222. config.log.enabled = false;
  223. fetch_items().then(() => config.log.enabled = log_enabled);
  224. }, time_diff);
  225. }
  226.  
  227. function log_base_items_stats() {
  228. if (!config.log.enabled) return;
  229. const items_not_passed_by_type = get_items_by_index(get_not_passed_items(lu_obj.items), 'object', 'array');
  230. const get_item_name = itm => (itm.data.characters ? itm.data.characters : itm.data.slug);
  231. const get_item_stage = itm => (itm[scriptId].is_locked ? '🔒' : srs_stages[itm.assignments.srs_stage]);
  232. const get_next_study = itm => format_date_to_standard_output(itm[scriptId].current_earliest_study_date, true);
  233. const get_earliest_guru = itm => format_date_to_standard_output(itm[scriptId].earliest_guru_date, true);
  234. for (const itype of Object.keys(items_not_passed_by_type).sort((a,b)=>b.localeCompare(a))) {
  235. const not_passed_items = items_not_passed_by_type[itype];
  236. if (!not_passed_items || not_passed_items.length === 0) continue;
  237. const locked_items = Array.from(get_locked_items(not_passed_items));
  238. const str = [];
  239. if (config.log.detailed) {
  240. const output_table = not_passed_items.map(itm => ({
  241. name: get_item_name(itm),
  242. stage: get_item_stage(itm),
  243. nextStudy: get_next_study(itm),
  244. earliestGuru: get_earliest_guru(itm),
  245. url: itm.data.document_url
  246. }));
  247. output_table.unshift({name:'Name', stage:'SRS Stage', nextStudy: 'Next Study Date', earliestGuru: 'Earliest Guru Date', url: 'URL'});
  248. const max_name_len = output_table.reduce((a,b)=>b.name.length > a ? b.name.length : a, 0) + 1;
  249. const max_stage_len = output_table.reduce((a,b)=>b.stage.length > a ? b.stage.length : a, 0) + 1;
  250. const max_study_len = output_table.reduce((a,b)=>b.nextStudy.length > a ? b.nextStudy.length : a, 0) + 1;
  251. const max_guru_len = output_table.reduce((a,b)=>b.earliestGuru.length > a ? b.earliestGuru.length : a, 0) + 1;
  252. for (const {name, stage, nextStudy, earliestGuru, url} of output_table) {
  253. str.push(name.padEnd(max_name_len, ' ')+'\t'+
  254. stage.padEnd(max_stage_len, ' ')+'\t'+
  255. nextStudy.padEnd(max_study_len, ' ')+'\t'+
  256. earliestGuru.padEnd(max_guru_len, ' ')+'\t'+
  257. url);
  258. }
  259. }
  260. console.log('%s%s%o\n%s',
  261. `${not_passed_items.length} remaining ${itype}${itype==='radical'&&not_passed_items.length>1 ? 's' : ''} to guru`,
  262. locked_items.length > 0 ? ` (${locked_items.length} of which are still locked)` : '',
  263. not_passed_items,
  264. str.join('\n')
  265. );
  266. }
  267. }
  268.  
  269. // ========================================================================
  270. // Formatting
  271. // -------------------------------------------------------------------
  272.  
  273. function get_text_for_icon_tooltip() {
  274. let items = lu_obj.items_not_passed_with_assignments_available;
  275. return (items && items.length > 0 ? `${items.length} item${items.length===1 ? '' : 's'} needed to level ${(items.length===1 ? 'is' : 'are')} currently available to study` : '');
  276. }
  277.  
  278. function format_two_dates_diff_to_minimal_output(date, date2, include_seconds) {
  279. let diff = Math.max(0, Math.trunc(date.getTime()/1000)-Math.trunc(date2.getTime()/1000));
  280. let dd = Math.floor(diff / 86400);
  281. diff -= dd*86400;
  282. let hh = Math.floor(diff / 3600);
  283. diff -= hh*3600;
  284. let mm = Math.floor(diff / 60);
  285. diff -= mm*60;
  286. let ss = diff;
  287. if (dd > 0) {
  288. return dd+' day'+(dd===1?'':'s')+', '+hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  289. } else if (hh > 0) {
  290. return hh+' hour'+(hh===1?'':'s')+', '+mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  291. } else if (mm > 0 || ss > 0) {
  292. if (!include_seconds && ss > 30) mm++;
  293. return mm+' min'+(mm===1?'':'s')+(include_seconds?', '+ss+' sec'+(ss===1?'':'s'):'');
  294. } else {
  295. return 'Now';
  296. }
  297. }
  298.  
  299. function format_date_to_standard_output(date, include_differential, include_seconds) {
  300. if (!(date instanceof Date)) date = new Date(date);
  301. if (date.getTime() === (new Date(0)).getTime()) return 'N/A';
  302. if (date.getTime() <= lu_obj.load_time.getTime()) return "Now";
  303. let str = date.toLocaleString([],{weekday:"short",month:"short",day:"numeric",hour12:false,hour:"numeric",minute:"numeric"});
  304. if (!include_differential) return str;
  305. return str +' ('+format_two_dates_diff_to_minimal_output(date,lu_obj.load_time, include_seconds)+')';
  306. }
  307.  
  308. // ========================================================================
  309. // Transformers and Helpers
  310. // -------------------------------------------------------------------
  311.  
  312. /**
  313. * Returns a non-destructive Array of elements that are not found in
  314. * any of the parameter arrays.
  315. * Assumes all items have a property named "id"
  316. *
  317. * @param {...Array} var_args Arrays to compare.
  318. */
  319. function unique_from(arr1, ...args) {
  320. if (!args.length) return [];
  321. let out = [];
  322. let map = new Map();
  323. for (let n=0; n < args.length; n++) {
  324. let a2 = args[n];
  325. if (!(a2 instanceof Array))
  326. throw new TypeError( 'argument ['+n+'] must be an Array' );
  327. // Add existing id from the array to the map
  328. for (let i=0; i<a2.length; i++)
  329. map.set(a2[i].id, true);
  330. }
  331. // Add to the new array all items that aren't included in the map (map lookUp is O(1) complexity)
  332. for(let i=0; i<arr1.length; i++)
  333. if (!map.get(arr1[i].id))
  334. out.push(arr1[i]);
  335. return out;
  336. }
  337.  
  338. /**
  339. * Get an Array sort function with multiple subarray fields.
  340. * Prefix letter allows specifying whether to sort ascending "+" or descending "-" (default: ascending)
  341. * Splits and recurses properties properly on periods
  342. * e.g.
  343. * let arr = [{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'4'}, prop2: 2}];
  344. * arr.sort(get_sort_method('+prop2','-obj.prop1'));
  345. * // [{obj:{prop1:'4'}, prop2: 2},{obj:{prop1:'3'}, prop2: 2},{obj:{prop1:'1'}, prop2: 3},{obj:{prop1:'3'}, prop2: 4}]
  346. */
  347. function get_sort_method(){
  348. let argsArr = Array.prototype.slice.call(arguments);
  349. return function(a, b){
  350. for(let x in argsArr){
  351. let strStart = 1;
  352. let op = argsArr[x].substring(0,1);
  353. if (op !== "-" && op !== "+") {op = "+";strStart = 0;}
  354. let prop = argsArr[x].substring(strStart);
  355. prop = prop.split('.');
  356. let len = prop.length;
  357. let i = 0;
  358. let ax = a;
  359. let bx = b;
  360. let cx;
  361. while(i<len) {ax = ax[prop[i]]; bx = bx[prop[i]]; i++;}
  362. ax = (typeof ax == "string" ? ax.toLowerCase() : ax / 1);
  363. bx = (typeof bx == "string" ? bx.toLowerCase() : bx / 1);
  364. if(op === "-"){cx = ax; ax = bx; bx = cx;}
  365. if(ax !== bx){return ax < bx ? -1 : 1;}
  366. }
  367. };
  368. }
  369.  
  370. function add_dates_to_item(item) {
  371. if (!item) return;
  372. item[scriptId] = {is_locked: (!item.assignments || !item.assignments.unlocked_at)};
  373. item[scriptId].earliest_study_date = (!item[scriptId].is_locked ?
  374. new Date((item.assignments.started_at ?
  375. item.assignments.available_at :
  376. item.assignments.unlocked_at)) :
  377. get_earliest_unlock_date(item));
  378. item[scriptId].current_earliest_study_date = new Date(Math.max(item[scriptId].earliest_study_date.getTime(), lu_obj.load_time.getTime()));
  379. item[scriptId].earliest_guru_date = get_item_guru_date(item);
  380. }
  381.  
  382. function add_dates_to_items(items) {
  383. for (const itm of items)
  384. add_dates_to_item(itm);
  385. }
  386.  
  387. function* get_not_passed_items(items) {
  388. for (const itm of items)
  389. if (itm && (!itm.assignments || !itm.assignments.passed_at))
  390. yield itm;
  391. }
  392.  
  393. function* get_not_locked_but_not_passed_items(items) {
  394. for (const itm of items)
  395. if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at)
  396. yield itm;
  397. }
  398.  
  399. function* get_not_passed_items_with_available_assignments(items) {
  400. for (const itm of items)
  401. if (itm && itm.assignments && itm.assignments.unlocked_at && !itm.assignments.passed_at && itm[scriptId].current_earliest_study_date.getTime()<=lu_obj.load_time.getTime())
  402. yield itm;
  403. }
  404.  
  405. function* get_locked_items(items) {
  406. for (const itm of items)
  407. if (itm && (!itm.assignments || !itm.assignments.unlocked_at))
  408. yield itm;
  409. }
  410.  
  411. function get_items_by_index(items, field, type) {
  412. const index = {};
  413. switch (type) {
  414. case 'single':
  415. for (const itm of items) {
  416. if (itm[field] === undefined) { console.debug(`${itm}.${field} was undefined.`); continue; }
  417. index[itm[field]] = itm;
  418. }
  419. break;
  420. case 'array':
  421. default:
  422. for (const itm of items) {
  423. if (itm[field] === undefined) { console.debug(`${itm}.${field} was undefined.`); continue; }
  424. if (index[itm[field]] === undefined) index[itm[field]] = [];
  425. index[itm[field]].push(itm);
  426. }
  427. break;
  428. }
  429. return index;
  430. }
  431.  
  432. function get_next_soonest_study_items(items) {
  433. return items.reduce((acc,itm)=>{
  434. let min_date = acc.length>0 ? acc[0][scriptId].current_earliest_study_date : itm[scriptId].current_earliest_study_date;
  435. if (itm[scriptId].current_earliest_study_date.getTime() > lu_obj.load_time.getTime() && itm[scriptId].current_earliest_study_date.getTime() < min_date.getTime()) {
  436. min_date = itm[scriptId].current_earliest_study_date;
  437. acc.length = 0;
  438. }
  439. if (itm[scriptId].current_earliest_study_date.getTime() <= lu_obj.load_time.getTime() || itm[scriptId].current_earliest_study_date.getTime() === min_date.getTime())
  440. acc.push(itm);
  441. itm[scriptId].earliest_guru_date = get_item_guru_date(itm);
  442. return acc;
  443. },[]);
  444. }
  445.  
  446. /**
  447. * Gets the earliest date that the provided item can be unlocked if all components are gurued as soon as possible
  448. */
  449. function get_earliest_unlock_date(item) {
  450. if (!items_by_subject_id) return new Date(0);
  451. let min_date;
  452. for (const radIdx of item.data.component_subject_ids) {
  453. const rad = items_by_subject_id[radIdx];
  454. if (rad && rad[scriptId] && rad[scriptId].earliest_guru_date && (!min_date || rad[scriptId].earliest_guru_date.getTime() > min_date.getTime()))
  455. min_date = new Date(rad[scriptId].earliest_guru_date);
  456. }
  457. return min_date;
  458. }
  459.  
  460. /**
  461. * Calculate item guru date
  462. * TODO: Double-check whether the next guru time should be rounded up to the nearest hour
  463. */
  464. function get_item_guru_date(item){
  465. let hours_to_guru = 0;
  466. if (!item || !item[scriptId] || !item[scriptId].current_earliest_study_date && (!item.assignments || !item.assignments.unlocked_at))
  467. return new Date(0); // This is mostly for debugging. If you see the 12/31/1969 date anywhere, this is where it went wrong.
  468. if (item.assignments && item.assignments.passed_at)
  469. return new Date(item.assignments.passed_at);
  470. switch ((item.assignments ? item.assignments.srs_stage : 0) || 0) {
  471. case 0: hours_to_guru += 4+8+23+47; break;
  472. case 1: hours_to_guru += 8+23+47; break;
  473. case 2: hours_to_guru += 23+47; break;
  474. case 3: hours_to_guru += 47; break;
  475. }
  476. // Add the hours to the available date, or the unlock date if the item is locked, or current date something went wrong
  477. // Create a new date object so we don't modify the existing one
  478. const earliest_guru_date = (item[scriptId].current_earliest_study_date.getTime() > lu_obj.load_time.getTime() ?
  479. new Date(item[scriptId].current_earliest_study_date) :
  480. new Date(lu_obj.load_time));
  481. earliest_guru_date.setHours(earliest_guru_date.getHours()+hours_to_guru);
  482. return earliest_guru_date;
  483. }
  484.  
  485. /**
  486. * Get earliest possible level up date
  487. * Calculated by sorting the kanji by earliest possible guru date and taking the time from the 90% to last kanji
  488. */
  489. function get_level_up_date() {
  490. if (!items_by_type.kanji) return new Date(0);
  491. let kanji_items = items_by_type.kanji.sort((a,b)=>a[scriptId].earliest_guru_date.getTime()-b[scriptId].earliest_guru_date.getTime());
  492. return new Date(kanji_items[Math.ceil(kanji_items.length * 0.9)-1][scriptId].earliest_guru_date);
  493. }
  494.  
  495. })(window.level_up_assistant = {});