WaniKani Workload Graph

adds a button to the heatmap that displays your average workload over time

当前为 2023-01-30 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WaniKani Workload Graph
// @namespace    rwesterhof
// @version      1.4.6
// @description  adds a button to the heatmap that displays your average workload over time
// @match        https://www.wanikani.com/
// @match        https://preview.wanikani.com/
// @match        https://www.wanikani.com/dashboard
// @match        https://preview.wanikani.com/dashboard
// @require      https://greasyfork.org/scripts/410909-wanikani-review-cache/code/Wanikani:%20Review%20Cache.js?version=1142519
// @run-at       document-end
// @license      GPL-3.0-or-later
// @grant        none
// ==/UserScript==
;
(function(wkof, review_cache) {
    'use strict';

    /* global $, wkof */

    if (!wkof) {
        let response = confirm('WaniKani Workload Graph script requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');

        if (response) {
            window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
        }

        return;
    }

    // --------------------------- //
    // -------- TRIGGER ---------- //
    // --------------------------- //
    var graphButton =
        '<li class="sitemap__section"><button id="headerGraphButton" class="sitemap__section-header" data-navigation-section-toggle="" data-expanded="false" aria-expanded="false" type="button" onclick="window.graphReviews()">'
      +     '<span lang="ja">図表</span><span lang="en" class="font-sans">Graph</span>'
      + '</button></li>';

    var heatmapButton =
        '<button class="graph-button hover-wrapper-target" onclick="window.graphReviews()"'
            // todo: put in css for class graph-button
      +     'style="height: 30px; width: 30px; background-color: transparent !important; outline: none !important; box-shadow: none !important; color: var(--color); border: none; left: 25px; top: -5px;">'
      +    '<div class="hover-wrapper above">Workload graph</div>'
      +    '<i class="fa fa-bar-chart"></i>'
      + '</button>';
    // put graph button in header initially
    add_css();
    $('#sitemap > li:first-child').after(graphButton);
    // add event listener
    $('.dashboard')[0].addEventListener('heatmap-loaded', moveButtonToHeatmap);

    // eventlistener that moves the graph button to the heatmap once it is loaded
    function moveButtonToHeatmap(event) {
        $('#headerGraphButton').detach();
        $('#heatmap .views .reviews.view').prepend(heatmapButton);
    }

    // --------------------------- //
    // -- VARIABLES & CONSTANTS -- //
    // --------------------------- //
    const msInDay = 24 * 60 * 60 * 1000;
    const maxWidth = 900;
    const minWidth = maxWidth / 2;
    const maxPointSize = 10;
    const xAxisPadding = 50;
    const yAxisPadding = 50;
    // graph colors for detail stages (WLG, AT)
    const reviewStageColor = [ "#ff00b5", "#ee00a4", "#dd0093", "#cc0082", "#9439aa", "#882d9e", "#294ddb", "#0093dd" ];
    // graph colors for non-detail stages (WLG, AT)
    const reviewCategoryColor = [ "#dd0093", "#882d9e", "#294ddb", "#0093dd" ];
    // graph fill colors for level stage progression (LD)
    const stageColor = [ "#dd0093", "#dd0093", "#dd0093", "#dd0093", "#dd0093", "#dd0093", "#882d9e", "#882d9e", "#294ddb", "#0093dd", "#faac05" ];
    const stageThreshold = 0.05; // min fraction of items must pass to be considered at stage X - prevent (some) weird coloring for moved items
    // graph colors for reset shadow graphs (LD)
    const resetColors = ["#0000cc", "#00cc00", "#cccc00", "#cc00cc", "#00cccc", "#999999", "#0000aa", "#00aa00", "#aaaa00", "#aa00aa", "#00aaaa", "#666666", "#000099", "#009900", "#999900", "#990099", "#009999", "#660000", "#aaaaaa" ];
    const labelAll = { type: "all" };
    const labelTime = [ { type: "time", signal: 12 },   // show year labels, no dashes
                        { type: "time", signal: 3 },    // show year labels and quarterly dashes
                        { type: "time", signal: 1 } ];  // show year labels and monthly dashes
    const labelLevel = { type: "level", signal: 5 };    // x axis labels every signalLevel
    const labelToD = [ { type:"tod", signal: 3, format: "24h" },
                       { type:"tod", signal: 3, format: "12h" } ];
    const NOVALUE = -1;
    const MIN_REVIEWS_ACTUAL_LEVEL = 25;

    // constants determined during script run
    var startDate = null;  // the day of your first reviews

    // OPTIONS
    var CACHE_VERSION = '1.4';
    var CACHE_KEY = 'workload_graph_cache';
    function readCache() {
        var cached_json = localStorage.getItem(CACHE_KEY);
        if (cached_json) {
            var cached = JSON.parse(cached_json);
            if (cached.version == CACHE_VERSION) {
                options = cached;
                options.startAtDate[0]=new Date(options.startAtDate[0]); // de-json doesn't know this is supposed to be a date object
                options.startAtDate[2]=new Date(options.startAtDate[2]); // de-json doesn't know this is supposed to be a date object
                options.startAtDate[3]=new Date(options.startAtDate[3]); // de-json doesn't know this is supposed to be a date object
            }
            else if (cached.version == "1.3") {   // backwards compatible to 1.3
                options = cached;                                  // take the cached options
                options.startAtDate[0]=new Date(options.startAtDate[0]); // de-json doesn't know this is supposed to be a date object
                options.startAtDate[2]=new Date(options.startAtDate[2]); // de-json doesn't know this is supposed to be a date object
                options.version = CACHE_VERSION;                   // up the version
                                                                   // and add the new graph
                options.runningAverageDays(defaultOptions.runningAverageDays[3]);
                options.fillInd(defaultOptions.fillInd[3]);
                options.stageColorFill(defaultOptions.stageColorFill[3]);
                options.wlgReverse(defaultOptions.wlgReverse[3]);
                options.startAtDate(defaultOptions.startAtDate[3]);
                options.chosenTimeLabels(defaultOptions.chosenTimeLabels[3]);
                options.showDetailStages(defaultOptions.showDetailStages[3]);
                options.cumulativeReviews(defaultOptions.cumulativeReviews[3]);
                options.hideMovedItems.push(defaultOptions.hideMovedItems[3]);
            }
            else if (cached.version == "1.2") {   // backwards compatible to 1.2
                options = defaultOptions;
                options.runningAverageDays[0] = cached.runningAverageDays;
                options.fillInd[0] = cached.fillInd[0];
                options.fillInd[1] = cached.fillInd[1];
                options.stageColorFill[0] = cached.stageColorFill[0];
                options.stageColorFill[1] = cached.stageColorFill[1];
                options.wlgReverse[0] = cached.wlgReverse;
                options.startAtDate[0] = new Date(cached.startAtDate);
                options.chosenTimeLabels[0] = cached.chosenTimeLabels;
                options.showDetailStages[0] = cached.showDetailStages;
                options.cumulativeReviews[0] = cached.cumulativeReviews[0];
                options.cumulativeReviews[1] = cached.cumulativeReviews[1];
                options.hideMovedItems[1] = cached.hideMovedItems;
            }
        }
        else {
            options = defaultOptions;
        }
    }
    function cacheOptions() {
        // cache the options for next page load
        localStorage.setItem(CACHE_KEY, JSON.stringify(options));
    }
    var defaultOptions = {
        version: CACHE_VERSION,
                            // WLG               LD                AT                TOD
        runningAverageDays:  [ 7,                /*unused*/ 1,     30,               /*unused*/ 1     ],
        fillInd:             [ true,             false,            false,            false            ],
        stageColorFill:      [ false,            false,            false,            false            ],
        wlgReverse:          [ false,            /*unused*/ false, /*unused*/ false, /*unused*/ false ], // set to true to put enlightened on bottom and apprentice on top
        startAtDate:         [ null,             /*unused*/ null,  null,             null             ], // the chosen start date in the options
        chosenTimeLabels:    [ 1,                /*unused*/ 0,     1,                /*unused*/ 0,    ],
        showDetailStages:    [ false,            /*unused*/ false, false,            /*unused*/ false ],
        cumulativeReviews:   [ true,             false,            false,            false            ],
        hideMovedItems:      [ /*unused*/ false, true,             /*unused*/ false, /*unused*/ false ]
    };
    var options;
    function option(name) {
        return options[name][currentGraph];
    }
    function setOption(name, value) {
        options[name][currentGraph] = value;
    }


    // ------------------------------------ //
    // ----- RETRIEVAL AND STRIPPING ------ //
    // ------------------------------------ //
    var retrieved = false;
    var cachedStrippedData = null;
    // main button entry point takes care of retrieval and shows the workload graph
    async function graphReviews() {
        if (!options) readCache();

        if (!retrieved) {
            wkof.include('ItemData');
            await wkof.ready('ItemData')
                .then(displayProgressPane)
                .then(stripData);
           retrieved = true;
        }
        displayGraph();
    }
    window.graphReviews = graphReviews;

    // produces an empty stripped data object
    function getBlankStrippedData() {
         return { lastReviewDate: 0, reviewDays: [], reviewsPerLevel: [], levelUps: [], stagesPerLevel: [] };
    }

    // convert stage to category
    const categories = [ -1, 0, 0, 0, 0, 1, 1, 2, 3, 4 ];
    function toCategory(stage) {
        return categories[stage];
    }

    // produces an empty stripped data review day
    function getBlankReviewDayObj() {
        return { day: 0,
                 date: 0,
                 year: 0,
                 month: -1,
                 reviewsPerHour: Array(24).fill(0),
                 reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0],
                 reviewsPerCategory: [0, 0, 0, 0],
                 reviewsTotal: 0,
                 errorsPerHour: Array(24).fill(0),
                 errorsPerStage: [0, 0, 0, 0, 0, 0, 0, 0],
                 errorsPerCategory: [0, 0, 0, 0],
                 errorsTotal: 0
        };
    }

    // gets an inited count per level per day
    function getReviewsPerLevel(strippedData, level) {
        var curList = strippedData.reviewsPerLevel.pop();
        if (!curList) {
            curList = new Array(61);
        }
        strippedData.reviewsPerLevel.push(curList);
        if (!curList[level]) {
            curList[level] = { level: level, total: 0, errors: 0 };
        }
        return curList[level];
    }

    // pushes all > reset level reviews to a pre-reset review overview
    // keeps all < reset level reviews for the "current run" overview
    function processResetToLevel(strippedData, level) {
        var curList = strippedData.reviewsPerLevel.pop();
        if (!curList) {
            curList = new Array(61);
        }
        else {
            var preResetReviews = new Array(61);
            for (var index = level; index < curList.length; index++) {
                preResetReviews[index] = curList[index];
                delete curList[index];
            }
            strippedData.reviewsPerLevel.push(preResetReviews);
        }
        strippedData.reviewsPerLevel.push(curList);
    }

    // strips the review_cache down to what is needed for the current script
    async function stripData() {
        var data = await Promise.all([review_cache.get_reviews(), wkof.ItemData.get_items('assignments, include_hidden')]);

        var strippedData = getBlankStrippedData();
        if (data.length < 1 || data[0].length < 1 || data[0][0].length < 1) return strippedData;

        // get the item stage counts
        strippedData.stagesPerLevel = countStagesPerLevel(data[1]);

        // get the level ups
        strippedData.levelUps = await get_level_ups(data[1]);
        // then determine the resetTimestamps (if any)
        var previousLevel = 0;
        var resetTimestamps = strippedData.levelUps.reduce((resultObj, levelItem) => {
                if (levelItem[0] <= previousLevel) resultObj.push([ levelItem[0], levelItem[1].minTs ]);
                previousLevel = levelItem[0];
                return resultObj;
            }, []);

        // testing
//        var reviews = data[0].slice(0, 5000);
         var reviews = data[0];
        var minDate = new Date(data[0][0][0]);
        minDate.setHours(0,0,0,0);
        var minMs = minDate.getTime();
        startDate = new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate());
        var curYear = 0;
        var curMonth = -1;

        var wkDays = 0;
        var progressCounter = data[0].length;
        var resetToProcess = 0;
        const itemsById = await wkof.ItemData.get_index(await wkof.ItemData.get_items('include_hidden'), 'subject_id');

        var stripFunction = function(item) {
            if (--progressCounter % 100000 == 50000) {
                console.log("Processing... " + progressCounter + " reviews left");
            }
            var nextDateMs = minMs + (wkDays * msInDay);
            while (item[0] > nextDateMs) {
                var nextReviewDay = getBlankReviewDayObj();
                nextReviewDay.date = nextDateMs;
                var tempDate = new Date(nextReviewDay.date);
                var reviewYear = tempDate.getFullYear();
                if (reviewYear > curYear) {
                    nextReviewDay.year = reviewYear;
                    curYear = reviewYear;
                }
                var reviewMonth = tempDate.getMonth(); // 0-11
                if (reviewMonth != curMonth) {
                    nextReviewDay.month = reviewMonth;
                    curMonth = reviewMonth;
                }
                wkDays++;
                nextDateMs += msInDay;

                nextReviewDay.day = wkDays;
                strippedData.reviewDays.push(nextReviewDay);
            }

            var reviewDay = strippedData.reviewDays[wkDays - 1];
            var resolvedCategory = toCategory(item[2]);
            reviewDay.reviewsPerStage[item[2] - 1]++;  // you never do reviews on locked (-1), unlessoned (0) or burned (9) items, so we push stages 1-8 into array slots 0-7
            reviewDay.reviewsPerCategory[resolvedCategory]++;
            reviewDay.reviewsTotal++;

            var errorCountToAdd = (item[3]+item[4]==0?0:1);
            reviewDay.errorsPerStage[item[2] - 1] += errorCountToAdd;
            reviewDay.errorsPerCategory[resolvedCategory] += errorCountToAdd;
            reviewDay.errorsTotal += errorCountToAdd;

            if (   (resetToProcess < resetTimestamps.length)
                && (resetTimestamps[resetToProcess][1] <= item[0])
               ) {
                processResetToLevel(strippedData, resetTimestamps[resetToProcess][0]);
                resetToProcess++;
            }
            var fullItem = itemsById[item[1]];
            var level = (fullItem && fullItem.data) ? fullItem.data.level : 0;
            var reviewsPerLevel = getReviewsPerLevel(strippedData, level);
            reviewsPerLevel.total++;
            reviewsPerLevel.errors += errorCountToAdd;

            strippedData.lastReviewDate = item[0];
        };

        reviews.forEach(stripFunction);

        cachedStrippedData = strippedData;
        return strippedData;
    }

    // plain copy from the heatmap script - wonder if we can store this somewhere so it can be reused rather than refetched. Also
    // requires retrieval of all the items from wkof.
    // ...and we adapted it. I'm using this method to also determine the exact (as exact as possible) timestamps of resets
    // Get level up dates from API and lesson history
    async function get_level_ups(items) {
        let level_progressions = await wkof.Apiv2.get_endpoint('level_progressions');
        let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].data.unlocked_at;
        // Find indefinite level ups by looking at lesson history
        let levels = {};
        // Sort lessons by level then unlocked date
        items.forEach(item => {
            if (item.object !== "kanji" || !item.assignments || !item.assignments.unlocked_at || item.assignments.unlocked_at >= first_recorded_date) return;
            let date = new Date(item.assignments.unlocked_at).toDateString();
            if (!levels[item.data.level]) levels[item.data.level] = {};
            // altered : store an object with count and min timestamp instead of just a count
            if (!levels[item.data.level][date]) levels[item.data.level][date] = { minTs: item.assignments.unlocked_at, count: 1 };
            else {
                // altered: store an object with count and min timestamp instead of just a count
                levels[item.data.level][date].count++;
                if (item.assignments.unlocked_at < levels[item.data.level][date].minTs) levels[item.data.level][date].minTs = item.assignments.unlocked_at;
            }
        });
        // Discard dates with less than 10 unlocked
        // then discard levels with no dates
        // then keep earliest date for each level
        for (let [level, data] of Object.entries(levels)) {
            // altered as we now have a object in stead of just a count
            for (let [date, countObj] of Object.entries(data)) {
                if (countObj.count < 10) delete data[date];
            }
            if (Object.keys(levels[level]).length == 0) {
                delete levels[level];
                continue;
            }
            // altered, instead of a resulting date we store date and min timestamp per level
            var minDate = Object.keys(data).reduce((low, curr) => low < curr ? low : curr, Date.now());
            var minData = levels[level][minDate];
            levels[level] = { date: minDate, minTs: Date.parse(minData.minTs) };
        }
        // Map to array of [[level0, date0], [level1, date1], ...] Format
        levels = Object.entries(levels).map(([level, date]) => [Number(level), date]);
        // Add definite level ups from API
        // altered to provide the same object format (date + min Timestamp)
        Object.values(level_progressions).forEach(level => levels.push([level.data.level, { date: new Date(level.data.unlocked_at).toDateString(), minTs: Date.parse(level.data.unlocked_at) }]));
        return levels;
    }

    function countStagesPerLevel(items) {
        return items.reduce((counts, item) => {
                if (item.assignments && !item.assignments.hidden) {
                    var level = item.data.level;
                    if (!counts[level]) counts[level] = { level: level, total: 0, stages: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }; // locked, init and 9 stages, shifted 1 up (locked == -1)
                    var stage = item.assignments.srs_stage;
                    counts[level].stages[stage+1]++;
                    counts[level].total++; // total on level
                }
                return counts;
            }, new Array(61));
    }

    // -------------------------------------- //
    // ------------- PROCESSING ------------- //
    // -------------------------------------- //
    var init = [ false, false, false ];
    var graphs = [ "workloadGraph", "levelDifficultyGraph", "accuracyOverTimeGraph" ];
    var currentGraph = 0;
    // determines if the current graph must be redrawn or simply displayed
    function displayGraph() {
        if (!init[currentGraph]) {
            var progressPane = $('#' + graphs[currentGraph]);
            progressPane.removeClass('hidden');

            var dataPoints = toDataPoints(cachedStrippedData);
            var graphPoints = scaleGraph(dataPoints);
            drawGraph(graphPoints);
            init[currentGraph] = true;
        }
        else {
            $('#' + graphs[currentGraph]).removeClass('hidden');
        }
    }

    // inits a blank data point object
    // used in both WLG and AT graphs
    function getBlankWLGATDataPointObj() {
        return { point: 0,
                 year: 0,
                 month: -1,
                 levelUp: 0,
                 nrOfDays: 0,
                 reviewsPerStage: [0, 0, 0, 0, 0, 0, 0, 0],
                 reviewsPerCategory: [0, 0, 0, 0],
                 reviewsTotal: 0,
                 errorsPerStage: [0, 0, 0, 0, 0, 0, 0, 0],
                 errorsPerCategory: [0, 0, 0, 0],
                 errorsTotal: 0
        };
    }

    // retrieves the matching data point object from the array. Inits if not yet existing
    function getWLGATDataPoint(dataPoints, index) {
        if (index >= dataPoints.length) return null;

        if (!dataPoints[index]) {
            dataPoints[index] = getBlankWLGATDataPointObj();
            dataPoints[index].point = index;
        }
        return dataPoints[index];
    }

    // adds the values of two arrays together
    function addArrayTotals(arrayOne, arrayTwo) {
        if (arrayOne == null) return arrayTwo;
        if (arrayTwo == null) return arrayOne;
        if (arrayTwo.length < arrayOne.length) return addArrayTotals(arrayTwo, arrayOne);
        for (var index = 0; index < arrayOne.length; index++) {
            arrayOne[index] += arrayTwo[index];
        }
        return arrayOne;
    }

    function toDataPoints(strippedData) {
        switch(currentGraph) {
            case 0:
                return toWLGDataPoints(strippedData);
            case 1:
                return toLDDataPoints(strippedData);
            case 2:
                return toATDataPoints(strippedData);
            default:
                return [];
        }
    }

    // accumulate reviewdata to data points
    function toWLGDataPoints(strippedData) {

        var reviewDays = strippedData.reviewDays;
        var moveInitialYearLabel = false;
        // chop for startAtDate
        if (option("startAtDate")) {
            var startMs = option("startAtDate").getTime();
            reviewDays = reviewDays.filter((reviewDay) => (reviewDay.date >= startMs));
            moveInitialYearLabel = true;
        }

        var nrOfEntries = reviewDays.length;
        console.log("Scaling for " + nrOfEntries + " review days");
        var daysPerPoint = Math.floor(nrOfEntries / maxWidth);
        if ((nrOfEntries % maxWidth > 0) || (daysPerPoint == 0)) {
            daysPerPoint++;
        }
        var totalPoints = Math.floor(nrOfEntries / daysPerPoint);
        if (nrOfEntries % daysPerPoint > 0) {
            totalPoints++;
        }

        var dataPoints = new Array(totalPoints + 1);
        var dayNr = 0; // can't use reviewDay.day anymore as we may not start at 1. Current approach requires a sorted listed though.
        var cookFunction = function(reviewDay) {
            dayNr++;
            for (var countDay = dayNr; countDay < dayNr + option("runningAverageDays"); countDay++) {
                var xValue = Math.ceil(countDay / daysPerPoint);
                var dataPoint = getWLGATDataPoint(dataPoints, xValue);
                if (!dataPoint) break;
                dataPoint.nrOfDays++;
                dataPoint.reviewsPerStage = addArrayTotals(dataPoint.reviewsPerStage, reviewDay.reviewsPerStage);
                dataPoint.reviewsPerCategory = addArrayTotals(dataPoint.reviewsPerCategory, reviewDay.reviewsPerCategory);
                dataPoint.reviewsTotal += reviewDay.reviewsTotal;

                // add xAxis labels for this point, if any
                if (countDay == dayNr) {
                    if (reviewDay.year != 0) {
                        dataPoint.year = reviewDay.year;
                    }
                    if (reviewDay.month != -1) {
                        dataPoint.month = reviewDay.month;
                    }
                    // when starting at a specific date we may have lost the initial year indicator
                    if (moveInitialYearLabel && dataPoint.point == 1) {
                        dataPoint.year = option("startAtDate").getFullYear();
                    }
                    const searchDate = new Date(reviewDay.date).toDateString();
                    let level = (strippedData.levelUps.find(a=>a[1].date==searchDate) || [undefined])[0];
                    // note that we overwrite any previous level label (if you level up and reset on the same day or on consecutive days that are merged)
                    // because we're only interested in the latest label that goes with this data point
                    if (level) {
                        dataPoint.levelUp = level;
                    }
                }
            }
        };
        reviewDays.forEach(cookFunction);

        // now we have datapointed the xaxis points properly, now do the same for yaxis points
        var dataPointSeries = new Array(dataPoints.length);
        var xAxisYears = new Array();
        var xAxisLevelUps = new Array();
        var yAxisMaxValue = 0;
        var pointFunction = function(dataPoint) {
            if (dataPoint == null) return;

            if (option("showDetailStages")) dataPointSeries[dataPoint.point] = [0, 0, 0, 0, 0, 0, 0, 0];
            else dataPointSeries[dataPoint.point] = [0, 0, 0, 0];

            var divider = dataPoint.nrOfDays;
            for (var index = 0; index < dataPointSeries[dataPoint.point].length; index++) {
                var useArray = dataPoint.reviewsPerCategory;
                if (option("showDetailStages")) useArray = dataPoint.reviewsPerStage;
                var startIndex = 0;
                var endIndex = useArray.length;
                if (option("wlgReverse") || !option("cumulativeReviews")) startIndex = index;
                if (!option("wlgReverse") || !option("cumulativeReviews")) endIndex = index;

                dataPointSeries[dataPoint.point][index] = getArraySum(useArray, startIndex, endIndex) / dataPoint.nrOfDays;
            }
            if (option("wlgReverse")) dataPointSeries[dataPoint.point].reverse();

            if (dataPoint.year != 0) {
                xAxisYears.push([ dataPoint.point, dataPoint.year ]);
            }
            else if (dataPoint.month != -1) {
                xAxisYears.push([ dataPoint.point, -1 * dataPoint.month ]); // month labels are negative numbers
            }

            if (dataPoint.levelUp != 0) {
                xAxisLevelUps.push([ dataPoint.point, dataPoint.levelUp ]);
            }

            yAxisMaxValue = dataPointSeries[dataPoint.point].reduce((max, item) => { return ((item > max) ? item : max); }, yAxisMaxValue);
        };

        dataPoints.forEach(pointFunction);

        var xAxisLabels = new Array();
        xAxisLabels.push({ labelType: labelTime[option("chosenTimeLabels")], labels: xAxisYears });
        xAxisLabels.push({ labelType: labelLevel, labels: xAxisLevelUps }); // by going in the array later, the labels overwrite the previous labels

        // drawing color for the series
        var seriesColors = null;
        if (option("showDetailStages")) seriesColors = [...reviewStageColor];
        else seriesColors = [...reviewCategoryColor];
        if (option("wlgReverse")) seriesColors.reverse();

        return { dataPointSeries: dataPointSeries, seriesColors: seriesColors, xAxisLabels: xAxisLabels, yAxisMaxValue: yAxisMaxValue, graphTitle: "Workload - reviews per day" };
    }

    // sums array values from index to index inclusive
    function getArraySum(array, fromIndexIncl, toIndexIncl) {
        if (!array) return 0;
        const minIndex = Math.min(Math.max(0, fromIndexIncl), array.length);
        const maxIndex = Math.min(array.length, toIndexIncl + 1);
        var result = 0;
        for (var index = minIndex; index < maxIndex; index++) {
            result += array[index];
        }
        return result;
    }

    // accumulate reviewdata to data points
    function toLDDataPoints(strippedData) {

        var maxLevel = 0;
        var maxFinder = function(levelList) {
            if ((levelList.length > 0) && levelList[0] && (levelList[0].total > 0)) {
                console.warn("Ignoring " + levelList[0].total + " reviews for unknown items. This can be an indication that the locally cached item list is corrupt. You can force a reload by removing the 'wkof.file_cache' from the indexedDB (note: this will also reset all script settings to default)");
            }
            var reviewMax = levelList.reduce((max, item) => {
                            // item exists
                return ((   item
                            // item's level is higher than what we've found so far
                         && (item.level > max)
                                // either we don't hide moved items
                         && (   !option("hideMovedItems")
                                // or this level has the required min MIN_REVIEWS_ACTUAL_LEVEL reviews (weeds out single items that were moved to a higher level)
                             || (item.total > MIN_REVIEWS_ACTUAL_LEVEL)
                            )
                        ) ? item.level : max);
            }, 1);

            if (reviewMax > maxLevel) {
                maxLevel = reviewMax;
            }
        }
        strippedData.reviewsPerLevel.forEach(maxFinder);
        console.log("Scaling for " + maxLevel + " levels");

        var dataPoints = new Array(maxLevel + 1);
        var yAxisMaxValue = 0;
        // convert levels by reset series to reset series by level
        var cookFunction = function(levelList) {
            for (var levelNr = 1; levelNr < dataPoints.length; levelNr++) {
                var seriesLevelInfo = levelList[levelNr];
                if (!dataPoints[levelNr]) dataPoints[levelNr] = new Array();
                if (!seriesLevelInfo) {
                    dataPoints[levelNr].push(NOVALUE);
                }
                else {
                    var yValue = 100 * seriesLevelInfo.errors / seriesLevelInfo.total; // error percentage per level
                    dataPoints[levelNr].push(yValue);
                    yAxisMaxValue = Math.max(yValue, yAxisMaxValue);
                }
            }
        };

        strippedData.reviewsPerLevel.forEach(cookFunction);
        // reverse the series to draw them in proper order
        dataPoints.forEach(item => item.reverse());

        var dataPointFillColors = new Array(maxLevel + 1);
        var calculateFillPerPoint = function(stagesPerLevel) {
            if (stagesPerLevel.level <= maxLevel) { // ignore higher level assignments in case of resets
                var fraction = 0;
                var cumulative = 0;
                for (var index = stagesPerLevel.stages.length - 1; index >= 0; index--) {
                    cumulative += stagesPerLevel.stages[index];
                    fraction = cumulative / stagesPerLevel.total;
                    if (fraction >= stageThreshold) {
                        // note that if stage if f.i. Guru2, then we don't count Guru1 items, even though both are colored as Guru... but this is such a small portion that
                        // I dare say it won't matter much. Most important is to get Bur, Enl and Mas correct
                        dataPointFillColors[stagesPerLevel.level] = { stage: index, fraction: fraction };
                        return;
                    }
                }
            }
        };
        strippedData.stagesPerLevel.forEach(calculateFillPerPoint);

        // x axis labels
        var xAxisLevelUps = new Array();
        for (var i = 1; i <= maxLevel; i++) {
            xAxisLevelUps.push([ i, i ]);
        }
        var xAxisLabels = new Array();
        xAxisLabels.push({ labelType: labelLevel, labels: xAxisLevelUps });

        // drawing color for the series
        var seriesColors = [...resetColors];

        return { dataPointSeries: dataPoints, seriesColors: seriesColors, dataPointFillColors: dataPointFillColors, xAxisLabels: xAxisLabels, yAxisMaxValue: yAxisMaxValue, graphTitle: "Level difficulty - error percentage", seriesOpacity: 0.4 };
    }

    // accumulate reviewdata to data points
    function toATDataPoints(strippedData) {

        var reviewDays = strippedData.reviewDays;
        var moveInitialYearLabel = false;
        // chop for startAtDate
        if (option("startAtDate")) {
            var startMs = option("startAtDate").getTime();
            reviewDays = reviewDays.filter((reviewDay) => (reviewDay.date >= startMs));
            moveInitialYearLabel = true;
        }

        var nrOfEntries = reviewDays.length;
        console.log("Scaling for " + nrOfEntries + " review days");
        var daysPerPoint = Math.floor(nrOfEntries / maxWidth);
        if ((nrOfEntries % maxWidth > 0) || (daysPerPoint == 0)) {
            daysPerPoint++;
        }
        var totalPoints = Math.floor(nrOfEntries / daysPerPoint);
        if (nrOfEntries % daysPerPoint > 0) {
            totalPoints++;
        }

        var dataPoints = new Array(totalPoints + 1);
        var dayNr = 0; // can't use reviewDay.day anymore as we may not start at 1. Current approach requires a sorted listed though.
        var cookFunction = function(reviewDay) {
            dayNr++;
            for (var countDay = dayNr; countDay < dayNr + option("runningAverageDays"); countDay++) {
                var xValue = Math.ceil(countDay / daysPerPoint);
                var dataPoint = getWLGATDataPoint(dataPoints, xValue);
                if (!dataPoint) break;
                dataPoint.nrOfDays++;
                dataPoint.reviewsPerStage = addArrayTotals(dataPoint.reviewsPerStage, reviewDay.reviewsPerStage);
                dataPoint.reviewsPerCategory = addArrayTotals(dataPoint.reviewsPerCategory, reviewDay.reviewsPerCategory);
                dataPoint.reviewsTotal += reviewDay.reviewsTotal;
                dataPoint.errorsPerStage = addArrayTotals(dataPoint.errorsPerStage, reviewDay.errorsPerStage);
                dataPoint.errorsPerCategory = addArrayTotals(dataPoint.errorsPerCategory, reviewDay.errorsPerCategory);
                dataPoint.errorsTotal += reviewDay.errorsTotal;

                // add xAxis labels for this point, if any
                if (countDay == dayNr) {
                    if (reviewDay.year != 0) {
                        dataPoint.year = reviewDay.year;
                    }
                    if (reviewDay.month != -1) {
                        dataPoint.month = reviewDay.month;
                    }
                    // when starting at a specific date we may have lost the initial year indicator
                    if (moveInitialYearLabel && dataPoint.point == 1) {
                        dataPoint.year = option("startAtDate").getFullYear();
                    }
                    const searchDate = new Date(reviewDay.date).toDateString();
                    let level = (strippedData.levelUps.find(a=>a[1].date==searchDate) || [undefined])[0];
                    // note that we overwrite any previous level label (if you level up and reset on the same day or on consecutive days that are merged)
                    // because we're only interested in the latest label that goes with this data point
                    if (level) {
                        dataPoint.levelUp = level;
                    }
                }
            }
        };
        reviewDays.forEach(cookFunction);

        // now we have datapointed the xaxis points properly, now do the same for yaxis points
        var dataPointSeries = new Array(dataPoints.length);
        var xAxisYears = new Array();
        var xAxisLevelUps = new Array();
        var yAxisMaxValue = 0;
        var pointFunction = function(dataPoint) {
            if (dataPoint == null) return;

            if (option("showDetailStages")) dataPointSeries[dataPoint.point] = [0, 0, 0, 0, 0, 0, 0, 0];
            else dataPointSeries[dataPoint.point] = [0, 0, 0, 0];

            var divider = dataPoint.nrOfDays;
            for (var index = 0; index < dataPointSeries[dataPoint.point].length; index++) {
                var reviewArray = dataPoint.reviewsPerCategory;
                var errorArray = dataPoint.errorsPerCategory;
                if (option("showDetailStages")) {
                    reviewArray = dataPoint.reviewsPerStage;
                    errorArray = dataPoint.errorsPerStage;
                }
                if (reviewArray[index] == 0) {
                    dataPointSeries[dataPoint.point][index] = NOVALUE;
                }
                else {
                    dataPointSeries[dataPoint.point][index] = 100*(reviewArray[index] - errorArray[index]) / reviewArray[index];
                }
            }

            if (dataPoint.year != 0) {
                xAxisYears.push([ dataPoint.point, dataPoint.year ]);
            }
            else if (dataPoint.month != -1) {
                xAxisYears.push([ dataPoint.point, -1 * dataPoint.month ]); // month labels are negative numbers
            }

            if (dataPoint.levelUp != 0) {
                xAxisLevelUps.push([ dataPoint.point, dataPoint.levelUp ]);
            }

            yAxisMaxValue = dataPointSeries[dataPoint.point].reduce((max, item) => { return ((item > max) ? item : max); }, yAxisMaxValue);
        };

        dataPoints.forEach(pointFunction);

        var xAxisLabels = new Array();
        xAxisLabels.push({ labelType: labelTime[option("chosenTimeLabels")], labels: xAxisYears });
        xAxisLabels.push({ labelType: labelLevel, labels: xAxisLevelUps }); // by going in the array later, the labels overwrite the previous labels

        // drawing color for the series
        var seriesColors = null;
        if (option("showDetailStages")) seriesColors = [...reviewStageColor];
        else seriesColors = [...reviewCategoryColor];

        return { dataPointSeries: dataPointSeries, seriesColors: seriesColors, xAxisLabels: xAxisLabels, yAxisMaxValue: yAxisMaxValue, graphTitle: "Accuracy over time" };
    }


    // ---------------------------------------------- //
    // -------------- DISPLAY ----------------------- //
    // ---------------------------------------------- //
    function add_css() {
        $('head').append(
            `<style id="workload_graph_css">
                 div.wlg_graph {
                     background-color: var(--section-background,#f4f4f4);
                     position: fixed;
                     top: 100px;
                     left: 100px;
                     z-index: 1;
                     border: 1px solid var(--page-background);
                 }
                 .wlg_graph div:not(.wlg_canvas) {
                     position: absolute;
                     right:20px;
                     top:15%;
                     max-width:160px;
                 }
                 div.wlg_graph div {
                     color: var(--text-color);
                 }
                 div.wlg_graph div.wlg_canvas {
                     position: absolute;
                     background-color: var(--page-background);
                     top: 0px;
                     bottom: 0px;
                 }
                 div.wlg_graph div canvas {
                     color: var(--text-color,#000000);
                     --label-color: var(--vocabulary-color,#007700);
                 }
                 div.wlg_infoDiv {
                     font-size: 11px;
                 }
                 div.wlg_graph div input[type='checkbox'] {
                     vertical-align:top;
                 }
                 div.wlg_graph div input.indented {
                     margin-left:15px;
                 }
                 div.wlg_graph div input[maxLength='4'] {
                     width:100px;
                 }
                 div.wlg_graph div input[maxLength='4'].yearInput {
                     width:50px;
                 }
                 div.wlg_graph div input[maxLength='2'] {
                     width:25px;
                 }
                 div.wlg_graph div select {
                     width:100px;
                 }
                 div.wlg_graph div button {
                     cursor:pointer;
                 }
                 div.wlg_graph button.iconButton {
                     cursor:pointer;
                     position:absolute;
                     top: 10px;
                     right: 10px;
                     border: 0px;
                     width: 24px;
                 }
             </style>`);
    }

    const graphDiv = [
        '<div id="workloadGraph" class="wlg_graph"></div>',
        '<div id="levelDifficultyGraph" class="wlg_graph hidden" ></div>',
        '<div id="accuracyOverTimeGraph" class="wlg_graph hidden"></div>'
    ];
    const infoDiv = [
        '<div id="workloadGraphInfoDiv" class="wlg_infoDiv hidden">'
        + 'X-axis: date and level you were at that date<br/> Y-axis: number of reviews completed on the given date<br/>'
        + 'Reviews per stage<br/>'
        + '<span style="color:#dd0093;">Apprentice</span><br/>'
        + '<span style="color:#882d9e;">Guru</span><br/>'
        + '<span style="color:#294ddb;">Master</span><br/>'
        + '<span style="color:#0093dd;">Enlightened</span><br/>'
        + 'Note: burned items are never reviewed and thus not visible in this graph'
        + '</div>',
        '<div id="levelDifficultyGraphInfoDiv" class="wlg_infoDiv hidden">'
        + 'X-axis: level of items in WK<br/> Y-axis: error percentage for items of the given level<br/> Shadow graphs indicate stats prior to your resets.<br/>'
        + 'Note: as items are reviewed until burned, percentages from lower levels can still change. The graph shows a current snapshot.<br/>Highest stage achieved per level<br/>'
        + '<span style="color:#dd0093;">Apprentice</span><br/>'
        + '<span style="color:#882d9e;">Guru</span><br/>'
        + '<span style="color:#294ddb;">Master</span><br/>'
        + '<span style="color:#0093dd;">Enlightened</span><br/>'
        + '<span style="color:#faac05;">Burned</span><br/>'
        + '</div>',
        '<div id="accuracyOverTimeGraphInfoDiv" class="wlg_infoDiv hidden">'
        + 'X-axis: date and level you were at that date<br/> Y-axis: review accuracy (%)<br/>'
        + 'Accuracy per stage<br/>'
        + '<span style="color:#dd0093;">Apprentice</span><br/>'
        + '<span style="color:#882d9e;">Guru</span><br/>'
        + '<span style="color:#294ddb;">Master</span><br/>'
        + '<span style="color:#0093dd;">Enlightened</span><br/>'
        + '</div>'
    ];

    const optionDiv = [
        '<div id="workloadGraphOptionsDiv" class="">'
        + '<input type="checkbox" id="cumulativeWLG" title="Show cumulative reviews" checked> Cumulative reviews</input><br/>'
        + '<input type="checkbox" id="reverseLayers" class="indented" title="Check to put enlightened on the bottom and apprentice at the top"> Reverse layers</input><br/>'
        + '<input type="checkbox" id="fillWLG" class="indented" title="Check to fill the graph, uncheck to use a line graph" checked> Fill graph</input><br/>'
        + '<input type="checkbox" id="detailStagesWLG" title="Split out Apprentice and Guru sub stages"> Detail stages</input><p/>'
        + '<span title="Number of days the running average of reviews is calculated over. A higher number leads to a smoother graph">Running average days</span><br/>'
        + '<input id="runningAverageInputWLG" maxlength="4"/><p/>'
        + '<input type="checkbox" id="startDateWLG" title="Uncheck to display all data"> <span title="Start date of graph in YYYY-MM-DD format">Start graph at date</span><br/>'
        + '<input id="startYearWLG" maxLength="4" class="yearInput" />-<input id="startMonthWLG" maxLength="2" />-<input id="startDayWLG" maxLength="2" /><p/>'
        + '<span title="Frequency of x Axis time labels">Time labels per</span><br/>'
        + '<select id="timeLabelsWLG"><option value="0">year</option><option value="1" selected>quarter</option><option value="2">month</option></select><p/>'
        + '<button onclick="window.adjustWLGOptions()">Redraw</button></div>',

        '<div id="levelDifficultyGraphOptionsDiv" class="">'
        + '<input type="checkbox" id="fillLD" title="Check to fill the graph base on stage progress, uncheck to use a line graph"> Add stage colors</input><br/>'
        + '<input type="checkbox" id="hideMovedItemsLD" title="Hide items that were moved to a higher level you have not yet reached"> Hide moved items</input><p/>'
        + '<button onclick="window.adjustLDOptions()">Redraw</button></div>',

        '<div id="accuracyOverTimeGraphOptionsDiv" class="">'
        + '<input type="checkbox" id="detailStagesAT"  title="Split out Apprentice and Guru sub stages"> Detail stages</input><p/>'
        + '<span title="Number of days the running average of reviews is calculated over. A higher number leads to a smoother graph">Running average days</span><br/>'
        + '<input id="runningAverageInputAT" maxlength="4" /><p/>'
        + '<input type="checkbox" id="startDateAT" title="Uncheck to display all data"> <span title="Start date of graph in YYYY-MM-DD format">Start graph at date</span><br/>'
        + '<input id="startYearAT" maxLength="4" class="yearInput" />-<input id="startMonthAT" maxLength="2" />-<input id="startDayAT" maxLength="2" /><p/>'
        + '<span title="Frequency of x Axis time labels">Time labels per</span><br/>'
        + '<select id="timeLabelsAT"><option value="0">year</option><option value="1" selected>quarter</option><option value="2">month</option></select><p/>'
        + '<button onclick="window.adjustATOptions()">Redraw</button></div>'
    ];
    const optionDivWidth = 180;
    const closeGraphButton = '<button id="closeGraphButton" onclick="window.hideGraph()" title="Close window" class="iconButton"><i class="fa fa-minus"></i></button>';
    const toggleGraphButton = [
        '<button id="workloadGraphButton" onclick="window.toggleGraph(0)" title="Workload" class="iconButton"><i class="fa fa-bar-chart"></i></button>',
        '<button id="levelDifficultyGraphButton" onclick="window.toggleGraph(1)" title="Level Difficulty" class="iconButton"><i class="fa fa-flask"></i></button>',
        '<button id="accuracyOverTimeGraphButton" onclick="window.toggleGraph(2)" title="Accuracy over Time" class="iconButton"><i class="fa fa-percent"></i></button>'
    ];
    const infoGraphButton = [
        '<button id="workloadGraphInfoButton" onclick="window.toggleInfoOnGraph(0)" title="toggle help" class="iconButton"><i class="fa fa-question"></i></button>',
        '<button id="levelDifficultyGraphInfoButton" onclick="window.toggleInfoOnGraph(1)" title="toggle help" class="iconButton"><i class="fa fa-question"></i></button>',
        '<button id="accuracyOverTimeGraphInfoButton" onclick="window.toggleInfoOnGraph(2)" title="toggle help" class="iconButton"><i class="fa fa-question"></i></button>'
    ];
    function hideGraph() {
        $('#' + graphs[currentGraph]).addClass('hidden');
    }
    window.hideGraph = hideGraph;
    function toggleGraph(toGraph) {
        hideGraph();
        currentGraph = toGraph;
        displayGraph();
    }
    window.toggleGraph = toggleGraph;

    function toggleInfoOnGraph(toGraph) {
        if($('#' + graphs[toGraph] + 'InfoDiv').hasClass('hidden')) {
            $('#' + graphs[toGraph] + 'OptionsDiv').addClass('hidden');
            $('#' + graphs[toGraph] + 'InfoDiv').removeClass('hidden');
        }
        else {
            $('#' + graphs[toGraph] + 'InfoDiv').addClass('hidden');
            $('#' + graphs[toGraph] + 'OptionsDiv').removeClass('hidden');
        }
    }
    window.toggleInfoOnGraph = toggleInfoOnGraph;

    // shorthand to parse input
    function parseWithDefault(value, defaultValue) {
        if (isNaN(value) || value == "") {
            return defaultValue;
        }
        return parseInt(value);
    }
    function minToDefault(value, minValue, defaultValue) {
        return (value < minValue) ? defaultValue : value;
    }
    function boundValue(value, minValue, maxValue) {
        return Math.min(Math.max(value, minValue), maxValue);
    }

    const minYear = 2017;
    const minDate = new Date(2017, 7, 1);
    // process new options and redraw
    function adjustWLGOptions() {
        var newDays = parseWithDefault($('#runningAverageInputWLG')[0].value, -1);
        newDays = minToDefault(newDays, 1, defaultOptions.runningAverageDays[0]);
        $('#runningAverageInputWLG')[0].value = newDays;
        setOption("runningAverageDays", newDays);

        setOption("cumulativeReviews", $('#cumulativeWLG').is(":checked"));
        if (!option("cumulativeReviews")) {
            setOption("fillInd", false);
        }
        else {
            setOption("fillInd", $('#fillWLG').is(":checked"));
        }
        setOption("wlgReverse", $('#reverseLayers').is(":checked"));
        setOption("showDetailStages", $('#detailStagesWLG').is(":checked"));

        // startDateWLG startYearWLG startMonthWLG startDayWLG
        // get date items from input if checked
        if ($('#startDateWLG').is(":checked")) {
            var useYear = parseWithDefault($('#startYearWLG')[0].value, -1);
            // minYear-1, maxYear+1, because the minDate/maxDate check will force the proper date without keeping strange dates and only altering the year
            useYear = boundValue(useYear, minYear-1, new Date().getFullYear()+1);
            var useMonth = parseWithDefault($('#startMonthWLG')[0].value, -1);
            useMonth = boundValue(useMonth, 1, 12);
            useMonth--; // Date uses months 0-11
            var useDay = parseWithDefault($('#startDayWLG')[0].value, -1);
            useDay = boundValue(useDay, 1, 31);

            var useDate = new Date(useYear, useMonth, useDay);
            var maxDate = new Date();
            maxDate.setDate(maxDate.getDate() - 7); // roll back a week;
            if (useDate.getTime() > maxDate.getTime()) useDate = maxDate;
            if (useDate.getTime() < minDate.getTime()) useDate = minDate;

            setOption("startAtDate", useDate);
        }
        else {
            setOption("startAtDate", startDate);
        }
        $('#startYearWLG')[0].value = option("startAtDate").getFullYear();
        $('#startMonthWLG')[0].value = option("startAtDate").getMonth() + 1;
        $('#startDayWLG')[0].value = option("startAtDate").getDate();

        setOption("chosenTimeLabels", $('#timeLabelsWLG')[0].value);

        // cache the options for next page load
        cacheOptions();

        init[currentGraph] = false;
        displayGraph();
    }
    window.adjustWLGOptions = adjustWLGOptions;

    // process new options and redraw
    function adjustLDOptions() {
        setOption("stageColorFill", $('#fillLD').is(":checked"));
        setOption("hideMovedItems", $('#hideMovedItemsLD').is(":checked"));

        // cache the options for next page load
        cacheOptions();

        init[currentGraph] = false;
        displayGraph();
    }
    window.adjustLDOptions = adjustLDOptions;

    // process new options and redraw
    function adjustATOptions() {
        var newDays = parseWithDefault($('#runningAverageInputAT')[0].value, -1);
        newDays = minToDefault(newDays, 1, defaultOptions.runningAverageDays[2]);
        $('#runningAverageInputAT')[0].value = newDays;
        setOption("runningAverageDays", newDays);

        setOption("showDetailStages", $('#detailStagesAT').is(":checked"));

        // startDateAT startYearAT startMonthAT startDayAT
        // get date items from input if checked
        if ($('#startDateAT').is(":checked")) {
            var useYear = parseWithDefault($('#startYearAT')[0].value, -1);
            // minYear-1, maxYear+1, because the minDate/maxDate check will force the proper date without keeping strange dates and only altering the year
            useYear = boundValue(useYear, minYear-1, new Date().getFullYear()+1);
            var useMonth = parseWithDefault($('#startMonthAT')[0].value, -1);
            useMonth = boundValue(useMonth, 1, 12);
            useMonth--; // Date uses months 0-11
            var useDay = parseWithDefault($('#startDayAT')[0].value, -1);
            useDay = boundValue(useDay, 1, 31);

            var useDate = new Date(useYear, useMonth, useDay);
            var maxDate = new Date();
            maxDate.setDate(maxDate.getDate() - 7); // roll back a week;
            if (useDate.getTime() > maxDate.getTime()) useDate = maxDate;
            if (useDate.getTime() < minDate.getTime()) useDate = minDate;

            setOption("startAtDate", useDate);
        }
        else {
            setOption("startAtDate", startDate);
        }
        $('#startYearAT')[0].value = option("startAtDate").getFullYear();
        $('#startMonthAT')[0].value = option("startAtDate").getMonth() + 1;
        $('#startDayAT')[0].value = option("startAtDate").getDate();

        setOption("chosenTimeLabels", $('#timeLabelsAT')[0].value);

        // cache the options for next page load
        cacheOptions();

        init[currentGraph] = false;
        displayGraph();
    }
    window.adjustATOptions = adjustATOptions;

    function initOptions() {
        switch (currentGraph) {
            case 0:
                // WLG options
                $('#runningAverageInputWLG')[0].value = options.runningAverageDays[0];

                $('#cumulativeWLG').prop("checked", options.cumulativeReviews[0]);
                $('#fillWLG').prop("checked", options.fillInd[0]);
                $('#reverseLayers').prop("checked", options.wlgReverse[0]);
                $('#detailStagesWLG').prop("checked", options.showDetailStages[0]);

                // startDateWLG startYearWLG startMonthWLG startDayWLG
                if (   (options.startAtDate[0].getFullYear() == startDate.getFullYear())
                    && (options.startAtDate[0].getMonth() == startDate.getMonth())
                    && (options.startAtDate[0].getDate() == startDate.getDate())
                   ) {
                    // no start date selected
                    $('#startDateWLG').prop("checked", false);
                }
                else {
                    $('#startDateWLG').prop("checked", true);
                    $('#startYearWLG')[0].value = options.startAtDate[0].getFullYear();
                    $('#startMonthWLG')[0].value = options.startAtDate[0].getMonth() + 1;
                    $('#startDayWLG')[0].value = options.startAtDate[0].getDate();
                }

                $('#timeLabelsWLG')[0].value = options.chosenTimeLabels[0];
                break;
            case 1:
                // LD options
                $('#fillLD').prop("checked", options.stageColorFill[1]);
                $('#hideMovedItemsLD').prop("checked", options.hideMovedItems[1]);
                break;
            case 2:
                // AT options
                $('#runningAverageInputAT')[0].value = options.runningAverageDays[2];
                $('#detailStagesAT').prop("checked", options.showDetailStages[2]);

                // startDateAT startYearAT startMonthAT startDayAT
                if (   (options.startAtDate[2].getFullYear() == startDate.getFullYear())
                    && (options.startAtDate[2].getMonth() == startDate.getMonth())
                    && (options.startAtDate[2].getDate() == startDate.getDate())
                   ) {
                    // no start date selected
                    $('#startDateAT').prop("checked", false);
                }
                else {
                    $('#startDate').prop("checked", true);
                    $('#startYearAT')[0].value = options.startAtDate[2].getFullYear();
                    $('#startMonthAT')[0].value = options.startAtDate[2].getMonth() + 1;
                    $('#startDayAT')[0].value = options.startAtDate[2].getDate();
                }

                $('#timeLabelsAT')[0].value = options.chosenTimeLabels[2];
                break;
        }
    }

    // indicate the user clicked the button by showing the init... pane - and set up all the graph divs
    function displayProgressPane() {
        for (var index = 0; index < graphs.length; index++) {
            $('.srs-progress').before(graphDiv[index]);
            var graphCanvas = document.createElement("canvas");
            graphCanvas.id = graphs[index] + "Canvas";
            graphCanvas.width = 200;
            graphCanvas.height = 200;
            var graphCanvasDiv = document.createElement("div");
            graphCanvasDiv.classList.add("wlg_canvas");
            graphCanvasDiv.append(graphCanvas);
            graphCanvasDiv.width = 200;
            graphCanvasDiv.height = 200;

            var progressPane = $('#' + graphs[index]);
            progressPane.append(graphCanvasDiv);
        }

        progressPane = $('#' + graphs[currentGraph]);
        progressPane.removeClass('hidden');
        clearGraph("Init..."); // turns out the graph refresh during processing doesn't want to trigger, so we log instead
    }

    // draws a single point on the canvas
    function drawPointBar(ctx, toX, zeroY, toY, xAxisPointSize, mockValue, useDataPointFillColors) {
        if (!useDataPointFillColors || mockValue) return;

        ctx.save();
        ctx.globalAlpha = useDataPointFillColors.fraction;
        ctx.strokeStyle = stageColor[useDataPointFillColors.stage];
        ctx.fillStyle = stageColor[useDataPointFillColors.stage];

        if (xAxisPointSize > 1) {
            ctx.fillRect(toX, toY, xAxisPointSize, (zeroY - toY));
        }
        else {
            ctx.moveTo(toX, zeroY);
            ctx.lineTo(toX, toY);
            ctx.stroke();
        }

        ctx.restore();
    }

    // draws a single point on the canvas
    function drawPoint(ctx, toX, toY, xAxisPointSize, fillIndicator, sameValue, firstPointInd) {
        if (fillIndicator || (!firstPointInd && !sameValue)) {
            ctx.lineTo(toX, toY);
        }
        else {
            ctx.moveTo(toX, toY);
        }
        if (xAxisPointSize > 1) {
            toX += xAxisPointSize - 1;
            if (fillIndicator || !sameValue) {
                ctx.lineTo(toX, toY);
            }
            else {
                ctx.moveTo(toX, toY);
            }
        }
    }

    // clears the current graph and optionally adds progress text
    function clearGraph(text) {
        var graphCanvas = $('#' + graphs[currentGraph] + 'Canvas')[0];
        var ctx = graphCanvas.getContext("2d");
        ctx.save();

        ctx.clearRect(0, 0, graphCanvas.width, graphCanvas.height);
        ctx.strokeStyle = "#000000";
        ctx.beginPath();
        ctx.rect(0, 0, graphCanvas.width, graphCanvas.height);
        ctx.stroke();

        if (text) {
            ctx.strokeText(text, xAxisPadding, yAxisPadding);
        }

        ctx.restore();
    }

    // translate review datapoints to graph points
    function scaleGraph(dataPoints) {

        // scaling of axes
        var xAxisMaxSize = dataPoints.dataPointSeries.length;
        var xAxisPointSize = 1;
        if (xAxisMaxSize < minWidth) {
            xAxisPointSize = Math.ceil(minWidth / xAxisMaxSize);
            xAxisMaxSize = xAxisPointSize * dataPoints.dataPointSeries.length;
            if (xAxisPointSize > maxPointSize) {
                xAxisPointSize = maxPointSize;
                xAxisMaxSize = minWidth;
            }
        }
        var yAxisMaxSize = Math.ceil(xAxisMaxSize * 9 / 16); // take a standard aspect ratio
        var yScale = 1;
        var decimalPlaces = 2;
        while (yScale < dataPoints.yAxisMaxValue) {
            yScale *=10;
            decimalPlaces--;
        }
        yScale /= 100;
        decimalPlaces = Math.max(0, decimalPlaces);
        var yAxisMaxValue = Math.ceil(dataPoints.yAxisMaxValue * 1.2 / yScale) * yScale; // let the values climb to abou 80% of the graph
        var yAxisPointValue = yAxisMaxValue / yAxisMaxSize; // not an integer

        // scale the y values of the series
        var graphPointSeries = new Array(dataPoints.dataPointSeries.length);
        for (var index = 0; index < dataPoints.dataPointSeries.length; index++) {
            var dataPoint = dataPoints.dataPointSeries[index];
            if (dataPoint == null) continue;
            graphPointSeries[index] = dataPoint.map((a) => (a == NOVALUE) ? NOVALUE : (a / yAxisPointValue));
        };

        // yAxis labels
        var nrOfDashes = 3;
        var yIndicatorsPer = Math.ceil(yAxisMaxValue / ((nrOfDashes+1) * yScale)) * yScale;
        var yAxisValues = new Array();
        for (var dash = 1; dash <= nrOfDashes; dash++) {
            yAxisValues.push([ ((yIndicatorsPer * dash) / yAxisPointValue), (yIndicatorsPer * dash).toFixed(decimalPlaces) ]);
        }
        var yAxisLabels = new Array();
        yAxisLabels.push({ labelType: labelAll, labels: yAxisValues });

        // return the accumulated graph data for drawing
        return {
            xAxisMaxSize: xAxisMaxSize,
            xAxisPointSize: xAxisPointSize,
            xAxisLabels: dataPoints.xAxisLabels,
            yAxisMaxSize: yAxisMaxSize,
            yAxisPointValue: yAxisPointValue,
            yAxisLabels: yAxisLabels,
            graphPointSeries: graphPointSeries,
            seriesColors: dataPoints.seriesColors,
            dataPointFillColors: dataPoints.dataPointFillColors,
            title: dataPoints.graphTitle,
            seriesOpacity: dataPoints.seriesOpacity
        };
    }

    // display the graph on canvas
    function drawGraph(graphData) {

        // resize and empty the canvas for redrawing
        var graphCanvas = $('#' + graphs[currentGraph] + 'Canvas')[0];
        graphCanvas.width = graphData.xAxisMaxSize + 2*xAxisPadding;
        graphCanvas.height = graphData.yAxisMaxSize + 2*yAxisPadding;
        var canvasDiv = $('#' + graphs[currentGraph] + ' div.wlg_canvas')[0];
        canvasDiv.width = graphData.xAxisMaxSize + 2*xAxisPadding;
        canvasDiv.height = graphData.yAxisMaxSize + 2*yAxisPadding;
        clearGraph();

        var ctx = graphCanvas.getContext("2d");
        if (graphData.graphPointSeries.length < 2) {
            ctx.strokeText("No data", xAxisPadding, yAxisPadding);
            return;
        }
        ctx.save();

        var baseY = graphCanvas.height - yAxisPadding;
        var useDataPointFillColors = null;
        for (var cat = graphData.graphPointSeries[1].length - 1; cat >= 0; cat--) {

            if ((cat > 0) && (graphData.seriesOpacity)) {
                // seriesOpacity is defined only if series beyond the first are 'greyed out'
                ctx.globalAlpha = graphData.seriesOpacity;
            }
            else {
                // first series is always full opacity
                ctx.globalAlpha = 1;
                if ((cat == 0) && option("stageColorFill")) {
                    useDataPointFillColors = graphData.dataPointFillColors;
                }
            }

            var currentX = xAxisPadding;
            var colorIndex = cat % graphData.seriesColors.length;

            ctx.fillStyle = graphData.seriesColors[colorIndex];
            ctx.strokeStyle = graphData.seriesColors[colorIndex];
            ctx.beginPath();
            ctx.moveTo(currentX, baseY);
            currentX++;

            // nb: 0th entry is not a point!
            var endPointIndicator = true;
            for (var pointIndex = 1; pointIndex < graphData.graphPointSeries.length; pointIndex++) {
                var mockValue = (graphData.graphPointSeries[pointIndex][cat] == NOVALUE);
                endPointIndicator = (endPointIndicator || mockValue);
                var currentY = baseY;
                if (!mockValue) {
                    currentY = baseY - graphData.graphPointSeries[pointIndex][cat];
                }
                var sameValue = option("cumulativeReviews") && (graphData.graphPointSeries[pointIndex][cat] == ((cat > 0) ? graphData.graphPointSeries[pointIndex][cat - 1] : 0));
                if (useDataPointFillColors) {
                    drawPointBar(ctx, currentX, baseY, currentY, graphData.xAxisPointSize, mockValue, useDataPointFillColors[pointIndex]);
                }
                drawPoint(ctx, currentX, currentY, graphData.xAxisPointSize, options.fillInd[currentGraph], (sameValue || mockValue), endPointIndicator);
                currentX += graphData.xAxisPointSize;
                endPointIndicator = (endPointIndicator && mockValue);
            }

            if (option("fillInd")) {
                ctx.lineTo(currentX - 1, baseY);
                ctx.lineTo(xAxisPadding, baseY);
                ctx.fill();
            }
            else {
                ctx.stroke();
            }
        }

        // set context back to default foreground color (css governed)
        var defaultForegroundColor = getComputedStyle(graphCanvas).getPropertyValue("color");
        // default to black
        if (defaultForegroundColor == 'transparent') {
            defaultForegroundColor = '#000000';
        }
        var defaultLabelColor = getComputedStyle(graphCanvas).getPropertyValue("--label-color");
        // default to dark green
        if (defaultLabelColor == 'transparent') {
            defaultLabelColor = '#007700';
        }

        // draw axes
        ctx.strokeStyle = defaultForegroundColor;
        // x-axis
        ctx.beginPath();
        ctx.moveTo(xAxisPadding, baseY);
        ctx.lineTo(xAxisPadding + graphData.xAxisMaxSize, baseY);
        ctx.stroke();
        // y-axis
        ctx.beginPath();
        ctx.moveTo(xAxisPadding, baseY);
        ctx.lineTo(xAxisPadding, baseY - graphData.yAxisMaxSize);
        ctx.stroke();

        var middleCorrection = Math.floor(graphData.xAxisPointSize / 2);
        // x axis labels
        var labelColors = [ defaultLabelColor, defaultForegroundColor ];
        var xColorOffset = labelColors.length - graphData.xAxisLabels.length % labelColors.length;
        for (var index = 0 ; index < graphData.xAxisLabels.length; index++) {
            colorIndex = (xColorOffset + index) % labelColors.length;
            ctx.strokeStyle = labelColors[colorIndex];

            var labelObj = graphData.xAxisLabels[index];
            for (var dashIndex = 0; dashIndex < labelObj.labels.length; dashIndex++) {
                var atX = xAxisPadding + labelObj.labels[dashIndex][0] * graphData.xAxisPointSize - middleCorrection;
                var addDashInd = true;
                var addLabelTextInd = true;
                var overrideLabelText = false;
                if (labelObj.labelType.type == "level") {
                    var levelLabel = parseInt(labelObj.labels[dashIndex][1]);
                    addLabelTextInd = (levelLabel % labelObj.labelType.signal == 0);
                    overrideLabelText = (   ((dashIndex > 0) && (levelLabel <= parseInt(labelObj.labels[dashIndex - 1][1])))   // add label upon reset
                                         || ((dashIndex == 0) && (levelLabel > 1))                                             // add label for first level if not 1 (pre 2017 starters)
                                        );
                }
                else if (labelObj.labelType.type == "time") {
                    var timeLabel = parseInt(labelObj.labels[dashIndex][1]);
                    if (timeLabel < 0) { // postive time labels are years, negative ones are month indicators
                        addDashInd = ((-1 * timeLabel) % labelObj.labelType.signal == 0);
                        addLabelTextInd = false;
                    }
                }

                if (addDashInd) {
                    var dashLength = (graphData.xAxisLabels.length - index - 1) * 15;
                    ctx.beginPath();
                    ctx.moveTo(atX, baseY);
                    ctx.lineTo(atX, baseY + dashLength + (addLabelTextInd ? 10 : 5));
                    ctx.stroke();
                    if (addLabelTextInd || overrideLabelText) {
                        var labelOffset = (labelObj.labels[dashIndex][1] + "").length * 3;
                        ctx.strokeText(labelObj.labels[dashIndex][1], atX - labelOffset, baseY + dashLength + 20);
                    }
                }
            }
        }

        // y axis labels
        var yColorOffset = labelColors.length - graphData.yAxisLabels.length % labelColors.length;
        for (index = 0 ; index < graphData.yAxisLabels.length; index++) {
            colorIndex = (yColorOffset + index) % labelColors.length;
            ctx.strokeStyle = labelColors[colorIndex];

            labelObj = graphData.yAxisLabels[index];
            for (dashIndex = 0; dashIndex < labelObj.labels.length; dashIndex++) {
                var atY = baseY - labelObj.labels[dashIndex][0];
                if (atY < yAxisPadding) break; // no labels above graph size
                var signalInd = true;
                var override = true;
                if (labelObj.labelType.type == "level") {
                    levelLabel = parseInt(labelObj.labels[dashIndex][1]);
                    signalInd = (levelLabel % labelObj.labelType.signal == 0);
                    override = (dashIndex > 0) && (levelLabel <= parseInt(labelObj.labels[dashIndex - 1][1]));
                }
                dashLength = (graphData.yAxisLabels.length - index - 1) * 30;
                ctx.beginPath();
                ctx.moveTo(xAxisPadding, atY);
                ctx.lineTo(xAxisPadding - dashLength - 5, atY); // no long dashes for signal levels
                ctx.stroke();
                if (signalInd || override) {
                    labelOffset = (labelObj.labels[dashIndex][1] + "").length * 7;
                    ctx.strokeText(labelObj.labels[dashIndex][1], xAxisPadding - labelOffset - 6, atY + 3);
                }
            }
        }

        // title
        ctx.font = "bold 24px Arial, sans-serif";
        ctx.fillStyle=defaultLabelColor;
        var titleOffset = graphData.title.length * 6;
        ctx.fillText(graphData.title, Math.floor(graphData.xAxisMaxSize / 2) - titleOffset + xAxisPadding, yAxisPadding - 10);
        ctx.restore();

        // complete the graph window
        var currentGraphDiv = $('#' + graphs[currentGraph]);
        // +20 px for the option div padding
        currentGraphDiv.css('width', graphCanvas.width + optionDivWidth + 20);

        // allow closing of the graph
        if (!$('#' + graphs[currentGraph] + ' #closeGraphButton')[0]) {
            currentGraphDiv.append(closeGraphButton);
        }
        if (!$('#' + graphs[currentGraph] + 'OptionsDiv')[0]) {
            currentGraphDiv.append(optionDiv[currentGraph]);
        }
        if (!$('#' + graphs[currentGraph] + 'InfoDiv')[0]) {
            currentGraphDiv.append(infoDiv[currentGraph]);
        }
        currentGraphDiv.css('height', Math.max(graphCanvas.height, 1.3 * $('#' + graphs[currentGraph] + 'OptionsDiv')[0].offsetHeight));

        // help/info button
        if (!$('#' + graphs[currentGraph] + 'InfoButton')[0]) {
            currentGraphDiv.append(infoGraphButton[currentGraph]);
            $('#' + graphs[currentGraph] + 'InfoButton').css('right', '38px');
        }
        // allow toggling of graph type
        var currentLocation = 38;
        for (index = toggleGraphButton.length - 1; index >= 0; index--) {
            if (index == currentGraph) continue;
            currentLocation +=28;
            if (!$('#' + graphs[currentGraph] + ' #' + graphs[index] + 'Button')[0]) {
                currentGraphDiv.append(toggleGraphButton[index]);
                $('#' + graphs[currentGraph] + ' #' + graphs[index] + 'Button').css('right', currentLocation + 'px');
            }
        }

        initOptions();
    }

    function consoleLog(obj) {
        $.each(obj, function(key, element) {
            console.log('key: ' + key + ', value: ' + element);
        });
    }


})(window.wkof, window.review_cache);