// ==UserScript==
// @name WaniKani Ultimate Timeline
// @namespace rfindley
// @description Review schedule explorer for WaniKani
// @version 6.0.9
// @include https://www.wanikani.com/
// @include https://www.wanikani.com/dashboard
// @include https://www.wanikani.com/review/session*
// @copyright 2015+, Robin Findley
// @license MIT; http://opensource.org/licenses/MIT
// @run-at document-end
// @grant none
// ==/UserScript==
wktimeln = {};
(function(gobj) {
var settings = {
'24hour': false,
'jp_font': 'Meiryo',
'show_detail': true,
'rescale_redraw': true,
'mark_current_vocab': true
};
// A list of Japanese fonts to appear in the Settings menu.
var jp_fonts = ['default','Hiragino Kaku Gothic Pro','Meiryo','Meiryo UI','Osaka','Yu Gothic','Yu Gothic UI','ヒラギノ角ゴ Pro W3','メイリオ','MS Pゴシック','sans-serif'];
var max_hours = 168,
levels_per_fetch = 5,
total_graph_height = 150,
graph_width_left = 28,
graph_height_top = 16,
graph_height_bottom = 16,
max_reviews, graph_hours, graph_reviews = 0, graph_review_total = 0,
graph_height_panel, graph_height, graph_width_panel, graph_width, graph_width_bar,
graph_hilight_x1, graph_hilight_x2, graph_hilight_mode = 0,
graph_range_slot1, graph_range_slot2, graph_detail_items, show_detail_while_dragging = true,
api_key, user_level = 1, user_data, timeline, status_div, calc_time, current_slot,
detail_latched = false, next_review, last_review, last_unlock, last_fetch, first_draw = true;
var srslvls = ['Apprentice 1','Apprentice 2','Apprentice 3','Apprentice 4','Guru 1','Guru 2','Master','Enlightened','Burned'];
var css =
'#graph-bar-info {'+
' position: absolute;'+
' padding: 4px 8px 8px 8px;'+
' color: #eeeeee;'+
' background-color: rgba(0,0,0,0.8);'+
' border-radius: 4px;'+
' font-weight: bold;'+
' z-index:2;'+
'}'+
'#graph-bar-info .summary {font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-size:13px; max-width:140px;}'+
'#graph-bar-info .summary div {padding:0px 8px;}'+
'#graph-bar-info .summary .indent {padding:0;}'+
'#graph-bar-info .summary .indent div {padding-left:16px;}'+
'#graph-bar-info .summary .tot {color:#000000; background-color:#efefef; background-image:linear-gradient(to bottom, #efefef, #cfcfcf);}'+
'#graph-bar-info .rad {background-color:#0096e7; background-image:linear-gradient(to bottom, #0af, #0093dd);}'+
'#graph-bar-info .kan {background-color:#ee00a1; background-image:linear-gradient(to bottom, #f0a, #dd0093);}'+
'#graph-bar-info .voc {background-color:#9800e8; background-image:linear-gradient(to bottom, #a0f, #9300dd); margin-bottom:8px;}'+
'#graph-bar-info .summary .cur {text-align:center; font-style:italic; color:#000000; background-color:#ffff88; background-image:linear-gradient(to bottom, #ffffaa, #eeee77);}'+
'#graph-bar-info .summary .bur {text-align:center; font-style:italic; color:#ffffff; background-color:#000000; background-image:linear-gradient(to bottom, #444444, #000000);}'+
'#graph-bar-info .detail {margin: 8px 0 0 0; padding: 0px;}'+
'#graph-bar-info .detail li {padding:0 3px; margin:1px 1px; display:inline-block; cursor:pointer; border-radius:4px; font-size:14px;}'+
'#graph-bar-info .detail li img {height:0.8em; width:0.8em;}'+
'div#graph-item-info {'+
' position: absolute;'+
' padding:8px;'+
' color: #eeeeee;'+
' background-color:rgba(0,0,0,0.8);'+
' border-radius:8px;'+
' font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;'+
' font-weight: bold;'+
' z-index:3;'+
'}'+
'#graph-item-info .item {font-size:2em; line-height:1.2em;}'+
'#graph-item-info .item img {height:1em; width:1em; vertical-align:bottom;}'+
'#graph-item-info>div {padding:0 8px; background-color:#333333;}'+
'section#timeln {margin-bottom: 0px; border-bottom: 1px solid #d4d4d4;}'+
'#timeln-graph {height:116px;}'+
'#timeln-graph div, #timeln-graph canvas {height:100%;width:100%;}'+
'#timeln-graph div {border:1px solid #d4d4d4;}'+
'form#range_form {'+
' float: right;'+
' margin-bottom: 0px;'+
' min-width: 50%;'+
' text-align: right;'+
'}'+
'section#timeln h4 {'+
' clear: none;'+
' float: left;'+
' height: 20px;'+
' margin-top: 0px;'+
' margin-bottom: 4px;'+
' font-weight: normal;'+
' margin-right: 12px;'+
'}'+
'@media (max-width: 767px) {section#timeln h4 {display: none;}}'+
'.dashboard section.review-status {border-top: 1px solid #ffffff;}'+
'.dashboard section.review-status ul li time {white-space: nowrap; overflow-x: hidden; height: 1.5em; margin-bottom: 0;}'+
'#timeline {overflow:hidden;}'+
'#timeline .grid {pointer-events:none;}'+
'#timeline .grid polyline {fill:none;stroke:black;stroke-linecap:square;shape-rendering:crispEdges;}'+
'#timeline .grid .light {stroke:#ffffff;}'+
'#timeline .grid .shadow {stroke:#d5d5d5;}'+
'#timeline .grid .major {opacity:0.15;}'+
'#timeline .grid .minor {opacity:0.05;}'+
'#timeline .grid .newday {stroke:#f22;opacity:1;}'+
'#timeline .grid .max {stroke:#f22;opacity:0.2;}'+
'#timeline text.newday {fill:#f22;font-weight:bold;}'+
'#timeline .label-x text {text-anchor:start;font-size:0.8em;}'+
'#timeline .label-y text {text-anchor:end;font-size:0.8em;}'+
'.noselect {-webkit-touch-callout:none; -webkit-user-select:none; -khtml-user-select:none; -moz-user-select:none; -ms-user-select:none; user-select:none; cursor:default;}'+
'#timeline text {pointer-events:none;}'+
'#timeline .bars rect {stroke:none;shape-rendering:crispEdges;}'+
'#timeline .rad {fill:#00a1f1;}'+
'#timeline .kan {fill:#f100a1;}'+
'#timeline .voc {fill:#a100f1;}'+
'#timeline .bars .cur {fill:#ffffff;opacity:0.6;}'+
'#timeline .bars .bur {fill:#000000;opacity:0.4;}'+
'#timeline .bars .clr {fill:#000000;opacity:0;cursor:pointer;}'+
'#timeline .arrows .bur {fill:#000000;stroke:#000000;stroke-width:0.5;}'+
'#timeline .arrows .cur {fill:#ffffff;stroke:#000000;stroke-width:0.5;}'+
'#timeline .hilight path {fill:#00a1f1; stroke:#00a1f1; stroke-width:2;}'+
'#timeline .hilight rect {fill:rgba(0,161,241,0.1); stroke:#00a1f1; stroke-width:1;}'+
'#timeln-modal {position:absolute; top:0; left:0; width:100%; height:100%; float:left; z-index:10;}'+
'#timeln .link {color:rgba(0,0,0,0.3); font-size:0.8em; font-weight:bold; text-decoration:none; cursor:pointer;}'+
'#timeln .link:hover {color:rgba(255,31,31,0.5);}'+
'#timeln .dialog {'+
' position:absolute;'+
' padding:8px;'+
' color:#eeeeee;'+
' background-color:#000;'+
' border-radius:8px;'+
' font-weight:bold;'+
' z-index:15;'+
'}'+
'#timeln .dialog h4 {'+
' width:100%;'+
' padding-bottom:4px;'+
' border-radius:4px;'+
' background-color:#f22;'+
' text-align:center;'+
' margin-bottom:20px;'+
'}'+
'#timeln .dialog button {margin:4px;}'+
'#timeln-settings label {'+
' display:inline-block;'+
' width:40%;'+
' margin-right:10px;'+
' text-align:right;'+
' font-weight:bold;'+
'}'+
'#timeln .font_sample {line-height:26px; padding-left:5px; vertical-align:middle; font-size:14px;}'+
'#timeln-settings .input {vertical-align:baseline;}'+
'#timeln .dialog .buttons {text-align:center; margin-bottom:4px; background-color:#222; border-radius:4px;}'+
'#timeln-settings form {margin-bottom:0;}'+
'#timeln-settings {width:450px;}'+
'#timeln-help {width:450px;}'+
'#timeln-help p {font-size:12px; font-weight:normal; text-shadow:0 0; line-height:1.2em;}'+
'#timeln-help p.new_section {border-top:1px solid #777; padding-top:8px;}'+
'#timeln-help b {font-size:14px; font-weight:bold; text-shadow:0 0; color:#ff8;}'
;
//-------------------------------------------------------------------
// Run when 'settings' link is clicked.
//-------------------------------------------------------------------
function click_settings() {
// Hide any other open windows.
$('#timeln-help').addClass('hidden');
var dialog = $('#timeln-settings');
// If settings dialog already exists, show it and exit.
if (dialog.length > 0) {
dialog.removeClass('hidden');
return;
}
// The dialog doesn't exist. Create it.
var _24hour = (get_setting('24hour') === true);
var jp_font = get_setting('jp_font');
var show_detail = (get_setting('show_detail') === true);
var rescale_redraw = (get_setting('rescale_redraw') === true);
var mark_current_vocab = (get_setting('mark_current_vocab') === true);
var str;
str =
'<div id="timeln-settings" class="dialog hidden">'+
' <h4>Timeline Settings</h4>'+
' <form>'+
' <label title="Display time in 24-hour mode (\'15:45\'), or 12-hour (\'3:45pm\').">Time Format</label>'+
' <select class="input" name="24hour" title="Display time in 24-hour mode (\'15:45\'), or 12-hour (\'3:45pm\').">'+
' <option value="false" '+(_24hour ? '' : 'selected')+'>12-hour</option>'+
' <option value="true" '+(_24hour ? 'selected' : '')+'>24-hour</option>'+
' </select>'+
' <label title="Sample text for previewing how Japanese text will be displayed.">Font Sample</label><span class="font_sample" lang="jp" title="Sample text for previewing how Japanese text will be displayed.">日本語を勉強していますか?</span><br />'+
' <label title="Choose the font to use for displaying Japanese text.">Japanese Font</label>'+
' <select class="input" name="jp_font" size="'+jp_fonts.length+'" style="vertical-align:top;" title="Choose the font to use for displaying Japanese text.">';
for (var idx = 0; idx < jp_fonts.length; idx++)
str += '<option value="'+jp_fonts[idx]+'"'+(jp_font === jp_fonts[idx] ? 'selected' : '')+'>'+jp_fonts[idx]+'</option>';
str +=
' </select>'+
' <label title="Show detailed contents of upcoming reviews.">Show Review Details</label>'+
' <select class="input" name="show_detail" title="Show detailed contents of upcoming reviews.">'+
' <option value="true" '+(show_detail ? 'selected' : '')+'>Yes</option>'+
' <option value="false" '+(show_detail ? '' : 'selected')+'>No</option>'+
' </select>'+
' <label title="Redraw the timeline while adjusting the timescale.">Redraw While Scaling</label>'+
' <select class="input" name="rescale_redraw" title="Redraw the timeline while adjusting the timescale.">'+
' <option value="true" '+(rescale_redraw ? 'selected' : '')+'>Yes</option>'+
' <option value="false" '+(rescale_redraw ? '' : 'selected')+'>No</option>'+
' </select>'+
' <label title="Show \'current level\' indicators for vocabulary.">Mark <i>Current</i> Vocab</label>'+
' <select class="input" name="mark_current_vocab" title="Show \'current level\' indicators for vocabulary.">'+
' <option value="true" '+(mark_current_vocab ? 'selected' : '')+'>Yes</option>'+
' <option value="false" '+(mark_current_vocab ? '' : 'selected')+'>No</option>'+
' </select>'+
' <p class="buttons">'+
' <button type="button" value="1">Save</button>'+
' <button type="button" value="0">Cancel</button>'+
' </p>'+
' </form>'+
'</div>';
// Add the dialog to the DOM, and display it.
dialog = $(str);
$('#timeln').append(dialog);
var t = $('#timeln').position();
dialog.css('top', t.top+25).css('left', t.left+10);
dialog.removeClass('hidden');
// Set up handler for the 'Save' and 'Cancel' buttons.
$('#timeln-settings button').on('click', function(e) {
dialog.addClass('hidden');
var save = e.target.value == 1;
if (save) {
set_setting('24hour', $('#timeln-settings .input[name="24hour"]').val()=="true");
set_setting('jp_font', $('#timeln-settings .input[name="jp_font"]').val());
set_setting('show_detail', $('#timeln-settings .input[name="show_detail"]').val()=="true");
set_setting('rescale_redraw', $('#timeln-settings .input[name="rescale_redraw"]').val()=="true");
set_setting('mark_current_vocab', $('#timeln-settings .input[name="mark_current_vocab"]').val()=="true");
draw_timeline();
} else {
var jp_font = get_setting('jp_font');
console.log('Reverting font back to "'+jp_font+'"');
$('#timeln-settings .input[name="24hour"]').val(get_setting('24hour').toString());
$('#timeln-settings .input[name="jp_font"]').val(jp_font);
$('#timeln-style').html('#timeln [lang="jp"] {font-family:'+jp_font+';}');
$('#timeln-settings .input[name="show_detail"]').val(get_setting('show_detail').toString());
$('#timeln-settings .input[name="rescale_redraw"]').val(get_setting('rescale_redraw').toString());
$('#timeln-settings .input[name="mark_current_vocab"]').val(get_setting('mark_current_vocab').toString());
}
});
// Set up handler to update the font when font selection changes.
$('#timeln-settings .input[name="jp_font"]').on('change', function(e) {
$('#timeln-style').html('#timeln [lang="jp"] {font-family:'+e.target.value+';}');
});
}
//-------------------------------------------------------------------
// Run when 'refresh' link is clicked.
//-------------------------------------------------------------------
function click_refresh() {
clear_cache();
$('#range_form, #graph-bar-info, #graph-item-info, #timeln-help, #timeln-settings').addClass('hidden');
$('#timeline').remove();
startup(true);
}
//-------------------------------------------------------------------
// Run when 'help' link is clicked.
//-------------------------------------------------------------------
function click_help() {
// Hide any other open windows.
$('#timeln-settings').addClass('hidden');
var dialog = $('#timeln-help');
// If settings dialog already exists, show it and exit.
if (dialog.length > 0) {
dialog.removeClass('hidden');
return;
}
// The dialog doesn't exist. Create it.
str =
'<div id="timeln-help" class="dialog hidden">'+
' <h4>Timeline Help</h4>'+
' <p><b>WaniKani Ultimate Timeline</b> displays a schedule of your upcoming reviews.</p>'+
' <p class="new_section">'+
' <b>X-axis:</b> Time when reviews become available.<br />'+
' <b>Y-axis:</b> Number of reviews in a timeslot.<br />'+
' <b>Range slider:</b> Set the number of hours to display on the graph (up to '+max_hours+' hours).</p>'+
' <p><b>Hover over a graph bar</b> to display a detail window, which shows details about the reviews in that timeslot.</p>'+
' <p><b>Click on a graph bar</b> to anchor the detail window, then hover over individual review items for individual item info.</p>'+
' <p><b>Click and drag along the top of the X-axis</b> to highlight a time range. The detail window will show details about all reviews in that time range.</p>'+
' <p><b>Current level reviews</b> are indicated by a white arrow below the timeslot, a white background behind the timeslot, and a yellow "Current Level" box in the detail window.<br />'+
' <b>Burn reviews</b> are indicated by a black arrow below the timeslot, a black background behind the timeslot, and a black "Burn Items" box in the detail window.</p>'+
' <p class="new_section">'+
' <b>Graph updates</b> occur automatically every 15 minutes, and the timescale slowly moves to the left.'+
' As time passes, your available reviews will accumulate in the left-most timeslot, which represents "now".</p>'+
' <p><b>Forced refresh</b> is like clearing your browser cache. It is usually only needed if you do reviews on a different device or computer.'+
' Normally, you only need to return to the WaniKani dashboard after doing reviews, and the timeline will fetch your updated schedule.</p>'+
' <p class="new_section">'+
' Contact: Robin Findley ([email protected])</p>'+
' <p class="buttons"><button type="button">Ok</button></p>'+
'</div>';
// Add the dialog to the DOM, and display it.
dialog = $(str);
$('#timeln').append(dialog);
var t = $('#timeln').position();
dialog.css('top', t.top+25).css('left', t.left+10);
dialog.removeClass('hidden');
// Set up handler for the 'Ok' button.
$('#timeln-help button').on('click', function(e) {
dialog.addClass('hidden');
});
}
//-------------------------------------------------------------------
// Change the value of a setting.
//-------------------------------------------------------------------
function set_setting(name, value) {
settings[name] = value;
localStorage.setItem('timeln_settings', JSON.stringify(settings));
}
//-------------------------------------------------------------------
// Clear timeline data cache.
//-------------------------------------------------------------------
function clear_cache() {
localStorage.removeItem('timeln_cache');
localStorage.removeItem('timeln_last_fetch');
localStorage.removeItem('timeln_last_review');
}
//-------------------------------------------------------------------
// Retrieve the value of a setting.
//-------------------------------------------------------------------
function get_setting(name) {
return settings[name];
}
//-------------------------------------------------------------------
// Close the modal window.
//-------------------------------------------------------------------
function close_modal() {
$('#timeln-modal').remove();
}
//-------------------------------------------------------------------
// Set up a full-screen modal window at z-index-10 to catch events.
//-------------------------------------------------------------------
function open_modal(events, handler) {
var modal = $('<div id="timeln-modal"></div>');
modal.height($(document).height());
$('body').prepend(modal);
modal.on(events, handler);
}
//-------------------------------------------------------------------
// Event handler for item details.
//-------------------------------------------------------------------
function item_info_event(e) {
var hinfo = $('#graph-item-info');
var target = $(e.target);
switch (e.type) {
//-----------------------------
case 'mouseenter':
var item;
var type = target.data('type');
var str = '<div class="'+type+'">';
switch (type) {
case 'rad':
item = graph_detail_items.radicals[target.data('idx')];
str += '<span class="item">Item: <span lang="jp">';
if (item.character !== null)
str += item.character+'</span></span><br />';
else
str += '<img src="'+item.image+'" /></span></span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
case 'kan':
item = graph_detail_items.kanji[target.data('idx')];
str += '<span class="item">Item: <span lang="jp">'+item.character+'</span></span><br />';
str += toTitleCase(item.important_reading)+': <span lang="jp">'+item[item.important_reading]+'</span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
case 'voc':
item = graph_detail_items.vocabulary[target.data('idx')];
str += '<span class="item">Item: <span lang="jp">'+item.character+'</span></span><br />';
str += 'Reading: <span lang="jp">'+item.kana+'</span><br />';
str += 'Meaning: '+toTitleCase(item.meaning)+'<br />';
if (item.user_specific.user_synonyms !== null && item.user_specific.user_synonyms.length > 0)
str += 'Synonyms: '+toTitleCase(item.user_specific.user_synonyms.join(', '))+'<br />';
str += 'Level: '+item.level+'<br />';
str += 'SRS Level: '+srslvls[item.user_specific.srs_numeric-1]+'<br />';
break;
}
str += '</div>';
hinfo.html(str);
hinfo.css('left', target.offset().left - target.position().left);
hinfo.css('top', Math.floor(target.offset().top + target.outerHeight() + 3));
hinfo.removeClass('hidden');
break;
//-----------------------------
case 'mouseleave':
hinfo.addClass('hidden');
break;
}
}
//-------------------------------------------------------------------
// Generate a formatted date string.
//-------------------------------------------------------------------
function format_date(time) {
var str;
if (time.getTime() === calc_time) return 'Now';
if (time.getDate() === (new Date()).getDate())
str = 'Today';
else
str = 'SunMonTueWedThuFriSat'.substr(time.getDay()*3, 3);
if (settings['24hour'])
str += ' ' + ('0' + time.getHours()).slice(-2) + ':' + '00153045'.substr(Math.floor(time.getMinutes()/15)*2, 2);
else
str += ' ' + ('0' + (((time.getHours()+11)%12)+1)).slice(-2) + ':' + '00153045'.substr(Math.floor(time.getMinutes()/15)*2, 2) + 'ap'[Math.floor(time.getHours()/12)] + 'm';
return str;
}
//-------------------------------------------------------------------
// Populate the info box.
//-------------------------------------------------------------------
function populate_info(slot_idx1, slot_idx2, hide_detail) {
// Check arguments, assign default values when missing.
if (slot_idx2 === undefined) slot_idx2 = slot_idx1+1;
if (hide_detail === undefined) hide_detail = false;
// Consolidate the selected range into a single structure of review items.
var si;
var si1 = Math.min(slot_idx1, slot_idx2);
var si2 = Math.max(slot_idx1, slot_idx2);
var hinfo = $('#graph-bar-info');
var slot_sum = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false};
for (si = si1; si < si2; si++) {
var slot = timeline[si];
if (slot === undefined) continue;
slot_sum.radicals = slot_sum.radicals.concat(slot.radicals);
slot_sum.kanji = slot_sum.kanji.concat(slot.kanji);
slot_sum.vocabulary = slot_sum.vocabulary.concat(slot.vocabulary);
slot_sum.item_count += slot.item_count;
slot_sum.has_current |= slot.has_current;
slot_sum.has_burn |= slot.has_burn;
}
// If no items are in the range, hide the detail window.
if (slot_sum.item_count === 0) {
hinfo.addClass('hidden');
return;
}
// Save a global copy of the consolidated info for use in support functions.
graph_detail_items = slot_sum;
// Print the date or date range).
var str = format_date(new Date(calc_time + si1 * 900000));
if (si2-si1 > 1) str += ' to ' + format_date(new Date(calc_time + si2 * 900000));
// Populate item type summaries.
str += '<div class="summary">';
str += '<div class="tot">'+(slot_sum.radicals.length+slot_sum.kanji.length+slot_sum.vocabulary.length)+' reviews</div>';
str += '<div class="indent">';
str += '<div class="rad">'+slot_sum.radicals.length+' radicals</div>';
str += '<div class="kan">'+slot_sum.kanji.length+' kanji</div>';
str += '<div class="voc">'+slot_sum.vocabulary.length+' vocabulary</div>';
str += '</div>';
if (slot_sum.has_current) str += '<div class="cur">Current Level</div>';
if (slot_sum.has_burn) str += '<div class="bur">Burn Items</div>';
str += '</div>';
// If details are enabled, populate the review-item list.
var idx, item;
var show_detail = get_setting('show_detail') && !hide_detail;
if (show_detail) {
str += '<ul class="detail">';
for (idx = 0; idx < slot_sum.radicals.length; idx++) {
item = slot_sum.radicals[idx];
str += '<li class="rad" lang="jp" data-type="rad" data-idx="'+idx+'">';
if (item.character !== null)
str += item.character+'</li>';
else
str += '<img src="'+item.image+'" /></li>';
}
for (idx = 0; idx < slot_sum.kanji.length; idx++) {
item = slot_sum.kanji[idx];
str += '<li class="kan" lang="jp" data-type="kan" data-idx="'+idx+'">'+item.character+'</li>';
}
for (idx = 0; idx < slot_sum.vocabulary.length; idx++) {
item = slot_sum.vocabulary[idx];
str += '<li class="voc" lang="jp" data-type="voc" data-idx="'+idx+'">'+item.character+'</li>';
}
str += '</ul>';
}
// We are done building the info box. Add it to the DOM.
hinfo.css('max-width', $('#timeln-graph').width()/2 - 15);
hinfo.html(str);
// Add event handlers for hovering review items.
if (show_detail) {
$('#timeln .detail').on('mouseenter', 'li', item_info_event);
$('#timeln .detail').on('mouseleave', item_info_event);
}
// If we are displaying a range, position the info box below the timeline.
// If the user is just hovering over a timeslot, the info box is positioned in a different function.
if (graph_hilight_mode != 0) {
var tlpos = $('#timeline').position();
if (si1 <= graph_hours*2)
hinfo.css('left', tlpos.left + Math.floor(Math.min(graph_hilight_x1,graph_hilight_x2)));
else
hinfo.css('left', tlpos.left + Math.floor(Math.max(graph_hilight_x1,graph_hilight_x2)) - hinfo.outerWidth());
hinfo.css('top', tlpos.top + graph_height_panel);
}
hinfo.removeClass('hidden');
}
//-------------------------------------------------------------------
// Event handler for time slots.
//-------------------------------------------------------------------
function bar_events(e) {
// Don't accept events while user is selecting a range.
if (detail_latched || graph_hilight_mode != 0) return;
var hinfo = $('#graph-bar-info');
var target = $(e.target);
var slot_idx = target.data('slot');
switch (e.type) {
//-----------------------------
case 'mousemove':
// We only want to redraw the info box just as we enter a new time slot.
if (slot_idx !== current_slot) {
current_slot = slot_idx;
// Populate the info box.
populate_info(slot_idx);
// Set the info box position, and unhide.
if (slot_idx < graph_hours*2)
hinfo.css('left', Math.floor(target.position().left + e.target.width.baseVal.value)+3);
else
hinfo.css('left', Math.floor(target.position().left - hinfo.outerWidth())-2);
}
// Update the vertical position even if we're on the
// same time slot, so box follows cursor vertically.
hinfo.css('top', e.pageY - 30);
break;
//-----------------------------
case 'mouseleave':
hinfo.addClass('hidden');
current_slot = undefined;
break;
//-----------------------------
case 'click':
detail_latched = true;
populate_info(slot_idx);
e.stopPropagation();
// Wait for a click anywhere on the document to close the info window.
$('body').on('click.close_tip', function(e) {
// Ignore clicks on the info box itself.
var tip = $('#graph-bar-info')[0];
if (e.target === tip || $.contains(tip, e.target)) return;
// Click was outside of info box. Close info box.
detail_latched = false;
current_slot = undefined;
hinfo.addClass('hidden');
$('body').off('.close_tip');
// If we clicked on another slot, make info sticky for that slot
// by simulating an additional click on that item.
if ($.contains($('#timeline .bars')[0], e.target)) {
var t = $(e.target);
t.trigger('mousemove');
t.trigger('click');
}
});
break;
}
}
//-------------------------------------------------------------------
// Event handler for overall graph.
//-------------------------------------------------------------------
function graph_events(e) {
var m1 = $('#timeline .marker:nth(0)');
var mr = $('#timeline .hilight rect')
var m2 = $('#timeline .marker:nth(1)');
var hinfo = $('#graph-bar-info');
switch (e.type) {
//-----------------------------
case 'mousemove':
var x = e.offsetX - graph_width_left;
if ((e.offsetY > graph_height_top || x < 0 || x >= graph_width) && graph_hilight_mode < 2) {
graph_hilight_mode = 0;
m1.attr('transform','translate(0 -1000)');
m2.attr('transform','translate(0 -1000)');
mr.attr('y','-1000');
break;
}
var slot = Math.round(x / graph_width_bar);
if (slot < 0) slot = 0;
if (slot > graph_hours*4) slot = graph_hours*4;
x = Math.floor(slot * graph_width_bar) + graph_width_left;
switch (graph_hilight_mode) {
//-----------------------------
case 0: // Idle mode, but mouse just entered the area for selecting range.
graph_hilight_mode++;
// Fall-through
//-----------------------------
case 1: // User is inside the area for selecting range. Display 'start' marker.
graph_range_slot1 = graph_range_slot2 = slot;
graph_hilight_x1 = graph_hilight_x2 = x;
m1.attr('transform','translate('+x+' '+graph_height_top+')');
break;
//-----------------------------
case 2: // User is dragging a range selection.
if (graph_range_slot2 === slot) break;
m2.attr('transform','translate('+x+' '+graph_height_top+')');
graph_range_slot2 = slot;
graph_hilight_x2 = x;
mr.attr('x',Math.min(graph_hilight_x1,graph_hilight_x2)).attr('y',graph_height_top)
mr.attr('width',Math.floor(Math.abs(graph_hilight_x2-graph_hilight_x1)));
var tlpos = $('#timeline').position();
populate_info(graph_range_slot1, graph_range_slot2, !show_detail_while_dragging);
break;
}
break;
//-----------------------------
case 'mousedown':
if (e.button != 0) break; // Only left mouse button
switch (graph_hilight_mode) {
//-----------------------------
case 1: // User is in area for selecting range, and just clicked to start selecting.
graph_hilight_mode = 2;
e.preventDefault();
e.stopPropagation();
var timeln_x = $('#timeline').offset().left;
open_modal('mousemove mousedown mouseup', function(e) {
e.offsetX -= timeln_x;
graph_events(e);
});
break;
//-----------------------------
case 2: // User clicked for 'end' range. (No longer used, since only click-drag-release is supported.)
$('#timeln-modal').css('z-index',1);
graph_hilight_mode = 3;
populate_info(graph_range_slot1, graph_range_slot2, false);
break;
//-----------------------------
case 3: // Range was already selected. Either close existing range, or start new range.
graph_hilight_mode = 0;
m1.attr('transform','translate(0 -1000)');
m2.attr('transform','translate(0 -1000)');
mr.attr('y','-1000');
hinfo.addClass('hidden');
close_modal();
// If user clicked again on timeline bar, start new range selection.
var t = $('#timeline');
var tx1 = graph_width_left;
var tx2 = tx1 + graph_width;
var ty1 = t.offset().top;
var ty2 = ty1 + graph_height_top;
var cx = e.offsetX;
var cy = e.offsetY;
if (cx >= tx1 && cx < tx2 && cy >= ty1 && cy < ty2) {
graph_hilight_mode = 1;
e.target = t[0];
e.offsetY -= t.offset().top;
e.type = 'mousemove';
graph_events(e);
e.type = 'mousedown';
graph_events(e);
} else {
}
break;
}
break;
//-----------------------------
case 'mouseup':
if (e.button != 0) break; // Only left mouse button
if (graph_hilight_mode != 2) break; // Only process release during drag.
// Check if user dragged, or only clicked.
if (graph_range_slot1 !== graph_range_slot2) {
$('#timeln-modal').css('z-index',1);
graph_hilight_mode = 3;
populate_info(graph_range_slot1, graph_range_slot2, false);
} else {
graph_hilight_mode = 1;
m2.attr('transform','translate(0 -1000)');
mr.attr('y','-1000');
hinfo.addClass('hidden');
close_modal();
}
break;
//-----------------------------
case 'mouseleave':
// User wasn't in the process of selecting a range, and the mouse
// left the area for selecting a range. Hide 'start' marker.
if (graph_hilight_mode < 2) {
m1.attr('transform','translate(0 -1000)');
m2.attr('transform','translate(0 -1000)');
mr.attr('y','-1000');
graph_hilight_mode = 0;
}
break;
}
}
//-------------------------------------------------------------------
// Event handler for hours slider.
//-------------------------------------------------------------------
function change_hours(e) {
graph_hours = Number($('#range_input').val());
localStorage.setItem('timeln_graph_hours', graph_hours);
$('#range_hours').text(graph_hours);
if (e.type === 'change' || get_setting('rescale_redraw'))
draw_timeline();
}
//-------------------------------------------------------------------
// Draw the timeline.
//-------------------------------------------------------------------
function draw_timeline() {
// Do some cleanup, in case redraw was triggered by 15min timer.
$('#graph-bar-info, #graph-bar-info').addClass('hidden');
close_modal();
graph_hilight_mode = 0;
// Update our timeline data based on cache.
calc_timeline();
// If cache says we have available items, but WK says next review
// date is in the future, user must have done reviews on another
// device. Need to force refresh.
var now = Math.floor(new Date()/1000);
if (first_draw === true && timeline[0] !== undefined && next_review >= Math.ceil(now/900)*900) {
first_draw = false;
setTimeout(click_refresh, 50); // Refresh after finishing main()
return;
}
// Update slider label with number of reviews on graph.
$('#range_reviews').text(graph_review_total);
// Calculate graph dimensions.
var timeln_graph = $('#timeln-graph');
$('#timeline').remove();
graph_height_panel = timeln_graph.height();
graph_height = graph_height_panel - (graph_height_top + graph_height_bottom);
graph_width_panel = timeln_graph.width();
graph_width = graph_width_panel - graph_width_left;
// String for building html.
var grid = '';
var label_x = '';
var label_y = '';
var bars = '';
var arrows = '';
// Calculate major and minor vertical graph tics.
var inc_s = 1, inc_l = 5;
while (Math.ceil(max_reviews / inc_s) > 5) {
switch (inc_s.toString()[0]) {
case '1': inc_s *= 2; inc_l *= 2; break;
case '2': inc_s = Math.round(2.5 * inc_s); break;
case '5': inc_s *= 2; inc_l *= 5; break;
}
}
// Draw vertical graph tics (# of Reviews).
var tic, tic_class, y;
graph_reviews = Math.ceil(max_reviews / inc_s) * inc_s;
for (tic = 0; tic <= graph_reviews; tic += inc_s) {
tic_class = ((tic % inc_l) === 0 ? 'major' : 'minor');
y = (graph_height_top + graph_height) - Math.round(graph_height * (tic / graph_reviews));
if (tic > 0)
grid += '<polyline class="'+tic_class+'" points="'+graph_width_left+','+y+' '+(graph_width_panel-1)+','+y+'" />';
label_y += '<text class="'+tic_class+'" x="'+(graph_width_left-4)+'" y="'+y+'" dy="0.4em">'+tic+'</text>';
}
// Set up to draw horizontal graph tics (Time).
var major_tic_choices = [1, 3, 6, 12, 24]; // Hours
var minor_tic_choices = [1, 4, 4, 12, 24]; // 15min intervals
var max_labels = Math.floor(graph_width / 50); // No more than 1 label every 50 pixels
var tic_choice = 0;
while ((graph_hours / major_tic_choices[tic_choice]) > max_labels && tic_choice < major_tic_choices.length) tic_choice++;
var major_tic = major_tic_choices[tic_choice] * 4;
var minor_tic = minor_tic_choices[tic_choice];
// Draw grid tics, and populate datapoints
var tic_ofs = Math.floor((calc_time - (new Date(calc_time)).setHours(0, 0, 0, 0)) / 900000);
graph_width_bar = (graph_width-1) / (graph_hours*4); // Width of a time slot.
for (tic = 0; tic <= graph_hours*4; tic++) {
var x = Math.floor(graph_width_left + tic * graph_width_bar);
// Need to use date function to account for time shifts (e.g. Daylight Savings Time)
tstamp = new Date(calc_time + tic * 900000);
var hh = tstamp.getHours();
var qh = hh*4 + Math.round(tstamp.getMinutes()/15);
// Check if we are on a Major Tic mark
if (qh % major_tic === 0) {
// Start of a new day?
if (hh === 0) {
tic_class = 'newday';
label = 'SunMonTueWedThuFriSat'.substr(tstamp.getDay()*3, 3);
} else {
tic_class = 'major';
tstamp = new Date(calc_time + tic * 900000);
var hh = tstamp.getHours();
if (settings['24hour']) {
label = ('0'+hh+':00').slice(-5);
} else {
label = (((hh + 11) % 12) + 1) + 'ap'[Math.floor(hh/12)] + 'm';
}
}
if (tic > 0)
grid += '<polyline class="'+tic_class+'" points="'+x+',0 '+x+','+(graph_height_top+graph_height-1)+'" />';
label_x += '<text class="'+tic_class+'" x="'+(x+4)+'" y="'+(graph_height_top-8)+'">'+label+'</text>';
} else if (qh % minor_tic === 0) {
// Minor Tic mark
if (tic > 0)
grid += '<polyline class="minor" points="'+x+','+(graph_height_top-6)+' '+x+','+(graph_height_top+graph_height-1)+'" />';
}
// If there are reviews for the current timeslot, draw graph bars.
var slot = timeline[tic];
if (slot && tic < graph_hours*4) {
var x1 = x - graph_width_left;
var x2 = Math.floor((tic+1) * graph_width_bar);
var base = 0;
var rad = slot.radicals.length;
var kan = slot.kanji.length;
var voc = slot.vocabulary.length;
if (rad > 0) {
bars += '<rect class="rad" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+rad+'" />';
base += rad;
}
if (kan > 0) {
bars += '<rect class="kan" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+kan+'" />';
base += kan;
}
if (voc > 0) {
bars += '<rect class="voc" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+voc+'" />';
base += voc;
}
// If current timeslot has current-level items, or items ready for Burning, draw indicator arrows.
var ay = graph_height_top+graph_height+1;
var ac = graph_width_left+(x1+x2)/2;
var ah = 7;
var aw = Math.min(x2-x1, ah);
var ax1 = ac-(aw/2);
var ax2 = ax1+aw;
if (slot.has_current) {
bars += '<rect class="cur" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+(graph_reviews-base)+'" />';
arrows += '<polygon class="cur" points="'+ac+','+ay+' '+ax1+','+(ay+ah)+' '+ax2+','+(ay+ah)+'" />';
}
ay += ah+1;
if (slot.has_burn) {
if (!slot.has_current)
bars += '<rect class="bur" x="'+x1+'" y="'+base+'" width="'+(x2-x1)+'" height="'+(graph_reviews-base)+'" />';
arrows += '<polygon class="bur" points="'+ac+','+ay+' '+ax1+','+(ay+ah)+' '+ax2+','+(ay+ah)+'" />';
}
bars += '<rect class="clr" x="'+x1+'" y="0" width="'+(x2-x1)+'" height="'+graph_reviews+'" data-slot="'+tic+'" />';
}
}
// Build the html/svg object from the components build above.
timeln_graph.append(
'<svg id="timeline" class="noselect" width="'+graph_width_panel+'" height="'+graph_height_panel+'">'+
' <g class="grid" transform="translate(0.5,0.5)">'+
grid+
' <polyline class="shadow" points="'+(graph_width_left-2)+',0 '+(graph_width_left-2)+','+(graph_height_top+graph_height-1)+'" />'+
' <polyline class="light" points="'+(graph_width_left-1)+',0 '+(graph_width_left-1)+','+(graph_height_top+graph_height-1)+'" />'+
' <polyline class="light" points="'+(graph_width_left-2)+','+(graph_height_top+graph_height+1)+' '+(graph_width_panel-1)+','+(graph_height_top+graph_height+1)+'" />'+
' <polyline class="shadow" points="'+(graph_width_left-2)+','+(graph_height_top+graph_height)+' '+(graph_width_panel-1)+','+(graph_height_top+graph_height)+'" />'+
' </g>'+
' <g class="label-x">'+
label_x+
' </g>'+
' <g class="label-y">'+
label_y+
' </g>'+
' <g class="arrows">'+
arrows+
' </g>'+
' <svg x="'+graph_width_left+'" y="'+graph_height_top+'" width="'+graph_width+'" height="'+graph_height+'" viewbox="0 0 '+graph_width+' '+graph_reviews+'" preserveAspectRatio="none">'+
' <g class="bars" transform="scale(1,-1) translate(0,-'+graph_reviews+')">'+
bars+
' </g>'+
' </svg>'+
' <g class="hilight">'+ //RJF
' <rect x="0" y="-1000" height="'+graph_height+'" transform="translate(0.5,0.5)" shape-rendering="crispEdges" />'+
' <path class="marker" transform="translate(0 -1000)" d="M 0 0 L -3 -5 L 3 -5 L 0 0 L 0 '+(graph_height+1)+'" />'+
' <path class="marker" transform="translate(0 -1000)" d="M 0 0 L -3 -5 L 3 -5 L 0 0 L 0 '+(graph_height+1)+'" />'+
' </g>'+
'</svg>'
);
// Add event handlers for the graph.
$('#timeline .bars .clr').on('mousemove mouseleave click', bar_events);
$('#timeline').on('mousemove mousedown mouseup mouseleave', graph_events);
// Schedule next timeline update, 1sec after next qtr hour.
var next_time = (new Date()).getTime();
next_time = Math.ceil(next_time/900000)*900000 - next_time + 1000;
setTimeout(function() {
draw_timeline();
}, next_time);
}
//-------------------------------------------------------------------
// Generate timeline data.
//-------------------------------------------------------------------
function calc_timeline() {
calc_time = new Date();
calc_time = calc_time.setMinutes(Math.floor(calc_time.getMinutes()/15)*15, 0, 0);
var next_time = Math.ceil(calc_time/900000); // Timestamp of next 15min slot
max_reviews = 3;
graph_review_total = 0;
var max_slot = graph_hours*4;
timeline = [];
types = ['radicals', 'kanji', 'vocabulary'];
for (var type_idx in types) {
var type = types[type_idx];
var item_cnt = user_data[type].length;
var mark_current = (type != 'vocabulary' || get_setting('mark_current_vocab'));
for (var item_idx = 0; item_idx < item_cnt; item_idx++) {
var item = user_data[type][item_idx];
var item_time = Math.floor(item.user_specific.available_date / 900); // Round down to 15min.
var slot_idx = Math.min(max_slot, Math.max(0, item_time - next_time));
if (timeline[slot_idx] === undefined)
timeline[slot_idx] = {radicals:[], kanji:[], vocabulary:[], item_count:0, has_current:false, has_burn:false, item_time:item_time*900};
var slot = timeline[slot_idx];
slot.item_count++;
if (slot_idx < max_slot) {
graph_review_total++;
if (slot.item_count > max_reviews)
max_reviews = slot.item_count;
}
slot[type].push(item);
if (mark_current && item.level == user_level)
slot.has_current = true;
if (item.user_specific.srs_numeric == 8)
slot.has_burn = true;
}
}
gobj.timeline = timeline;
}
//-------------------------------------------------------------------
// Make first letter of each word upper-case.
//-------------------------------------------------------------------
function toTitleCase(str) {
return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
}
//-------------------------------------------------------------------
// Add a <style> section to the document.
//-------------------------------------------------------------------
function addStyle(aCss) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
return style;
}
return null;
}
//-------------------------------------------------------------------
// Fetch a document from the server.
//-------------------------------------------------------------------
function ajax_retry(url, retries, timeout) {
retries = retries || 2;
timeout = timeout || 3000;
function action(resolve, reject) {
$.ajax({
url: url,
timeout: timeout
})
.done(function(data, status){
if (status === 'success')
resolve(data);
else
reject();
})
.fail(function(xhr, status, error){
if (status === 'error' && --retries > 0)
action(resolve, reject);
else
reject();
});
}
return new Promise(action);
}
//-------------------------------------------------------------------
// Fetch API key from account page.
//-------------------------------------------------------------------
function get_api_key() {
return new Promise(function(resolve, reject) {
api_key = localStorage.getItem('apiKey');
if (typeof api_key === 'string' && api_key.length == 32) return resolve();
status_div.html('Fetching API key...');
ajax_retry('/account').then(function(page) {
// --[ SUCCESS ]----------------------
// Make sure what we got is a web page.
if (typeof page !== 'string') {return reject();}
// Extract the user name.
page = $(page);
// Extract the API key.
api_key = page.find('#api-button').parent().find('input').attr('value');
if (typeof api_key !== 'string' || api_key.length !== 32) {return reject();}
localStorage.setItem('apiKey', api_key);
resolve();
},function(result) {
// --[ FAIL ]-------------------------
reject(new Error('Failed to fetch API key!'));
});
});
}
//-------------------------------------------------------------------
// Fetch review data.
//-------------------------------------------------------------------
function get_review_data() {
return new Promise(function(resolve, reject) {
status_div.html(
'Loading review data. Please be patient...<br>'+
'Radicals: <span class="radicals">0%</span><br>'+
'Kanji: <span class="kanji">0%</span><br>'+
'Vocabulary: <span class="vocabulary">0%</span>'
);
user_data = {radicals:[], kanji:[], vocabulary:[]};
var cnt = {radicals:0, kanji:0, vocabulary:0};
var max = Math.ceil(user_level/levels_per_fetch);
var max_date = next_review + (max_hours+1)*3600;
function fetch(type,lvl) {
$.getJSON('/api/user/'+api_key+'/'+type+'/'+lvl,function(json){
if (json.error && json.error.code === 'user_not_found') {
localStorage.removeItem('apiKey');
localStorage.removeItem('timeln_last_fetch');
location.reload();
reject();
return;
}
$(json.requested_information).each(function(i,v){
try {
if (v.user_specific.burned === false && v.user_specific.available_date < max_date)
user_data[type].push(v);
} catch(e) {}
});
cnt[type]++;
status_div.find('.'+type).html(''+Math.round(100*cnt[type]/max)+'%');
if (cnt.radicals == max && cnt.kanji == max && cnt.vocabulary == max) {
last_fetch = Math.floor(new Date()/1000);
localStorage.setItem('timeln_last_fetch', last_fetch);
localStorage.setItem('timeln_cache', JSON.stringify(user_data));
resolve();
}
});
}
var arr = [];
for (var lvl=1; lvl<=user_level; lvl++) {
arr.push(lvl);
if (((lvl % levels_per_fetch) === 0) || (lvl == user_level)) {
var str = arr.join(',');
fetch('radicals',str);
fetch('kanji',str);
fetch('vocabulary',str);
arr = [];
}
}
});
}
//-------------------------------------------------------------------
// Startup. Runs at document 'load' event.
//-------------------------------------------------------------------
function startup(warmboot) {
var now = Math.floor(new Date()/1000);
// If we are on the review screen, record the time, so we know our
// cache is out of date when we return to dashboard.
if (window.location.pathname == '/review/session') {
localStorage.setItem('timeln_last_review', now);
return;
}
// Clear cache if different user is logged in.
var last_user = localStorage.getItem('timeln_username') || '';
var current_user = $('.account a[href^="/community/people/"]').attr('href').split('/').pop();
if (current_user != last_user) clear_cache();
localStorage.setItem('timeln_username', current_user);
// Load settings.
if (localStorage.getItem('timeln_settings'))
$.extend(true, settings, JSON.parse(localStorage.getItem('timeln_settings')));
gobj.settings = settings;
graph_hours = localStorage.getItem('timeln_graph_hours');
if (!graph_hours) graph_hours = 36;
// Some DOM setup that we don't want to repeat if user forced refresh (i.e. 'warm boot').
if (warmboot !== true) {
addStyle(css);
var jp_font = get_setting('jp_font');
$('section.review-status').before(
'<section id="timeln">'+
' <style id="timeln-style">#timeln [lang="jp"] {font-family:'+jp_font+';}</style>'+
' <h4>Reviews Timeline</h4>'+
' <a id="timeln-settings-lnk" class="link" title="Change timeline settings">[settings]</a>'+
' <a id="timeln-refresh-lnk" class="link" title="Force full data refresh. Usually not necessary. Please use sparingly.">[force refresh]</a>'+
' <a id="timeln-help-lnk" class="link" title="Show instructions">[help]</a>'+
' <form id="range_form" class="hidden"><label><span id="range_reviews">0</span> reviews in <span id="range_hours">'+graph_hours+'</span> hours <input id="range_input" type="range" min="6" max="'+max_hours+'" value="'+graph_hours+'" step="6" name="range_input"></label></form><br clear="all">'+
' <div id="graph-bar-info" class="hidden"></div>'+
' <div id="graph-item-info" class="hidden"></div>'+
' <div id="timeln-graph"><div id="timeln-status" class="hidden"></div></div>'+
'</section>'
);
$('#timeln-settings-lnk').on('click', click_settings);
$('#timeln-refresh-lnk').on('click', click_refresh);
$('#timeln-help-lnk').on('click', click_help);
$('#timeln-graph').height(total_graph_height);
}
// Gather some info to help determine cache status.
user_level = Number($('.levels span:nth(0)').text());
next_review = Number($('.review-status .timeago').attr('datetime'));
last_review = Number(localStorage.getItem('timeln_last_review') || 0);
last_unlock = new Date($('.recent-unlocks time:nth(0)').attr('datetime'))/1000;
last_fetch = Number(localStorage.getItem('timeln_last_fetch') || 0);
// Workaround for "WaniKani Real Times" script, which deletes the element we were looking for above.
if (isNaN(next_review)) {
next_review = Number($('.review-status time1').attr('datetime'));
// Conditional divide-by-1000, in case someone fixed this error in Real Times script.
if (next_review > 10000000000) next_review /= 1000;
}
// Fetch API key and update cache, only if necessary.
var promise;
status_div = $('#timeln-status');
if (last_fetch <= last_unlock || last_fetch <= last_review || (next_review < now && last_fetch <= (now-3600))) {
status_div.removeClass('hidden');
status_div.html('Failed to fetch API key!');
promise = get_api_key()
.then(get_review_data)
.catch(function(e){status_div.html(e.message);});
} else {
promise = Promise.resolve();
}
// We have an up-to-date cache. Draw the timeline.
promise.then(function(){
// Fetch user data from cache.
user_data = JSON.parse(localStorage.getItem('timeln_cache'));
// Draw the timeline.
status_div.addClass('hidden');
draw_timeline();
// Install event handlers for time range slider.
$('#range_input').on('input change', change_hours);
$('#range_form').removeClass('hidden');
},null);
}
// Run startup() after window.onload event.
if (document.readyState === 'complete')
startup();
else
window.addEventListener("load", startup, false);
}(wktimeln));