[STATS] Statistics site

Understood.
If you ever end up deciding you want to get around to it and could use the key, feel free to let me know.

@rfindley So I went on a massive debugging spree to try to see if I could figure out what is going on. Why? I honestly don’t remember.
Anyway, it appears like the majority of the problem comes from the new additions to level items, which throws off the calc_levelup_from_vocab function, as far as I can tell.

In this instance, for example, , , , , and (all level 18) were created after I had already passed that level, but just by adding those to the pool, I was effectively no longer qualified to have passed it by the 90% rule (26/31=80%), at least according to the estimator function.
When I finally did pass them, that new later date gets calculated as my levelup date from that function for level 18. So then when the function goes next to level 19, and doesn’t encounter the same issue, and gives a proper estimate of the date, but since its start date was based on level 18’s end date, whabam: negative number.

For fun I tried creating a workaround, though I’m clearly too tired (I haven’t slept for like 36 hours or something ridiculous like that…) to cleanup the loose-ends and probable bugs. However, I did get a basic quick-and-dirty method “working” by just using kanji stats with passed_at instead of vocab. The following is that implementation:

function calc_levelups() {
    let level_times = wkstats.level_times = [];

    // For each level, initialize a valid range of possible level times (initial = any time)
    for (let level = 1; level <= wkof.user.subscription.max_level_granted; level++) {
        level_times[level] = {
            min: new Date(0),
            max: wkdata.load_time,
            dates: [],
            source: 'unknown',
        }
    }

    // Using level resets, throw out old level start times (by marking the min start time)
    for (let reset_idx = 0; reset_idx < wkdata.resets.length; reset_idx++) {
        let reset = wkdata.resets[reset_idx];
        let reset_time = new Date(reset.confirmed_at);
        for (let level = reset.target_level; level <= wkof.user.level; level++) {
            let level_time = level_times[level];

            // Ignore resets that happened before this level-up.
            if (reset_time < level_time.min) continue;

            // Update the min start time.
            level_time.min = reset_time;
            delete level_times[level].reset_time;
        }
        level_times[reset.target_level].reset_time = reset_time;
    }

    // Using the newest levelup record for each level, set known start times.
    let oldest_levelup = {index: -1, time: new Date('2999-01-01')};
    for (let levelup_idx = 0; levelup_idx < wkdata.levelups.length; levelup_idx++) {
        let levelup = wkdata.levelups[levelup_idx];
        let level = levelup.level;
        if (level > wkof.user.level) continue;
        let unlocked_time = new Date(levelup.unlocked_at);
        let level_time = level_times[level];

        // Check if this is the oldest recorded level-up, which may be invalid.
        if (unlocked_time < oldest_levelup.time) {
            oldest_levelup = {index: levelup_idx, time: unlocked_time, level: level};
        }

        // Ignore levelups that were invalidated by a reset.
        if (unlocked_time < level_time.min) continue;

        // Update the level start time.
        level_time.min = unlocked_time;
        level_time.source = 'APIv2 level_progressions';
        if (!levelup.abandoned_at && levelup.passed_at) {
            level_time.max = new Date(levelup.passed_at);
        } else if (level === wkof.user.level) {
            level_time.max = new Date();
        }
    }

    let items = wkdata.items


    let level_progressions = wkdata.levelups
    let first_recorded_date = level_progressions[Math.min(...Object.keys(level_progressions))].unlocked_at
    // Find indefinite level ups by looking at lesson history

    // Sort lessons by level then unlocked date
    items.forEach((item) => {
        if (
            (item.object !== 'kanji' && item.object !== 'radical') ||
            !item.assignments ||
            !item.assignments.unlocked_at ||
            item.assignments.unlocked_at >= first_recorded_date
        )
            return
        let date = new Date(item.assignments.unlocked_at)
        if (!level_times[item.data.level]) {
            level_times[item.data.level] = {}
        }
        if (!level_times[item.data.level].dates[date.toDateString()]) {
            level_times[item.data.level].dates[date.toDateString()] = [date]
        }
        else {
            level_times[item.data.level].dates[date.toDateString()].push(date)
        }
    })
    // Discard dates with less than 10 unlocked
    // then discard levels with no dates
    // then keep earliest date for each level
    for (let [level, {min, max, dates, source}] of Object.entries(level_times)) {
        for (let [date, data] of Object.entries(dates)) {
            if (data.length < 10)
                delete dates[date]
        }
        if (Object.keys(level_times[level].dates).length === 0) {
            delete level_times[level].dates
            continue
        }
        //level_times[level].min = Object.values(dates).reduce((low, curr) => (low < curr ? low : curr), Date.now()).sort((a, b) => (a.getTime() - b.getTime()))[0]
        level_times[level].min = Object.values(dates).reduce((acc,item)=>{let smallest=item.reduce((a,b)=>a<b?a:b);return acc<smallest ? acc : smallest}, new Date());
    }
    // Map to array of [[level0, date0], [level1, date1], ...] Format
    //levels = Object.entries(levels).map(([level, date]) => [Number(level), date])
    // Add definite level ups from API
    Object.values(level_progressions).forEach(lev => {
                                              if (level_times[lev.level].source === 'APIv2 level_progressions') return;
                                              level_times[lev.level] = {
        min: new Date(lev.unlocked_at),
        max: (lev.passed_at ? new Date(lev.passed_at) : wkdata.load_time),
        source: 'APIv2 level_progressions'
    }})

    for (let level = 1; level <= wkof.user.level; level++) {
        let level_data = level_times[level];
        if (level_data.source === 'APIv2 level_progressions') continue;
        if (level < level_times.length - 1) {
            let next_level_data = level_times[level+1];
            if (level_data.max.getTime() === wkdata.load_time.getTime())
                level_data.max = next_level_data.min;
        }
    }

    // Calculate durations
    let durations = wkstats.level_durations = [];
    for (let level = 1; level <= wkof.user.level; level++) {
        let level_time = level_times[level];
        durations[level] = (level_time.max - level_time.min) / 86400000;
    }

    log('levelups','--[ Level-ups ]----------------');
    let level_durations = wkstats.level_durations;
    // Log the current level statuses.
    log('levelups','Started: '+yyyymmdd(wkof.user.started_at));
    if (wkof.user.restarted_at) {
        log('levelups','Restarted: '+yyyymmdd(wkof.user.restarted_at));
    }
    for (let level = 1; level <= wkof.user.level; level++) {
        let level_time = level_times[level];
        let level_duration = level_durations[level];
        if (level_time.reset_time) {
            log('levelups','Reset');
        }
        // Flag any unusual level durations.
        if (level < wkof.user.level && (level_duration < 3.0 || level_duration > 2000))
            log('levelups','###################');
        log('levelups','Level '+level+': ('+yyyymmdd(level_time.min)+' - '+yyyymmdd(level_time.max)+') - '+
            duration(level_duration)+' (source: '+level_time.source+')');
    }

    wkof.set_state('wkof.wkstats.levelups', 'ready');
}

Again, it’s very likely I’m missing something important, but I was really focused and think I made some sort of progress.
¯\_(ツ)_/¯

Edit: Made some updates that make it a bit more accurate now.
There’s a date that I know I leveled up on that I cannot seem to get this to estimate properly, so my guess for that one is that it was a level that had items moved from it to another level at some point. Either way, this seems to be working fairly well for me.

Edit2: Re-did the entire thing with the calc_levelups() method, but used (and modified) @Kumirei’s heatmap level estimator in combination with the built-in implementation to get this abomination. Anyway, it appears to be working rather accurately. Though I’m not sure how performative it is.

3 Likes

wkstats doesn’t give the Level-up chart for me, after subscription ended + vacation mode.

No error message at all in Firefox, but in Brave has

Both Nihongo Stats and WK History create a Level-up chart successfully, but with an error message in the UI, not only console. (WK History also has pre-reset chart.)

Nihongo Stats

WK History