[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

I updated my script that modifies the stat calculation to be less cursed[1] and uploaded it to greasyfork if anyone is interested.

Really only applies to people who started and have levelups prior to the APIv2 level_progressions endpoint, but if that’s you then give it a go if your data on the stats site seems off.

WKStats Levelup Fix (greasyfork.org)


  1. I was previously doing some crazy injection and script overwriting that really only worked properly in Firefox because of the beforescriptexecute event. ā†©ļøŽ

1 Like

I’m bookmarking your post so that Somedayā„¢ I can update the site.

:smile:
I’ll admit it’s only a band-aid solution with less-than-ideal documentation and probable superfluous code. But I’m glad to hear it. In that case, I’ll futureproof this to only run when it sees calc_stats version 1.0.7.

19 days countdown starts now :wink:

Super useful! Is the projection broken though?

As long as I’ve been using this script since 2020, the projection hasn’t worked. I wonder what it would looke like tbh haha.

Is anyone interested in taking over ownership of wkstats.com? The domain is coming up for renewal, and since I’m not really involved in WK stuff anymore, I’m debating what to do with it.

According to the site logs, it had 706 unique IP visits yesterday (i.e. still well used), so I don’t want to just drop it… but if no one is interested in maintaining or developing it, I may eventually move it to a subdomain of a different site, rather than continuing to host it on its own domain.

8 Likes

Just curious, are you interested only in (transferring) the domain ownership, or code maintenance/development?

I’m hoping someone wants to develop/maintain the app, because a lot of people have said that it helps them stay motivated in their Japanese journey. Unfortunately, any time someone wants to contribute to the app’s development, they have to work with me since I operate the domain, and I no longer have time to invest in it. So, if someone has the motivation and skills to keep it alive, I would be happy to pass it on so it can continue to serve the WK community.

The costs to operate it are minimal: $5/mo for a Linode/Akamai server, and about $18/yr for the domain name.

I would also consider turning the domain name over to someone with a similar/equivalent app, as long as the WK community seems mostly positive about it.

2 Likes

I really appreciate wkstats.com. I find it incredibly helpful.

Personally, I wouldn’t really mind what address the site was at even if it was on a subdomain. I would offer to take over hosting it however I can see that is not your primary cost so i’m not sure how helpful that would be.

Thank you for all your work.

5 Likes

Yeah, cost isn’t an issue, especially since I’m using the same server for multiple things. But every year at renewal time, I always ask myself which domains I want to keep. And with wkstats, I always wonder if someone else would be interested in improving the site if they could take over the domain.

For example, the Projections page (which still says it’s ā€œunder constructionā€) has code that calculates your fastest possible path to finish WK, but it doesn’t display it because that’s where I ran out of time during v2.0 development. (You can see the calculation results in the Javascript Console, though.)

Someone even wrote a script to overlay their own projections page, and they were interested in possibly merging it into the site, but I never got the time to dig into it. I have so many projects that I want to do, but I can’t possibly finish them in one lifetime, so I need to keep narrowing it down.

Anyway, I’ll still renew the domain for at least 2026, but I figured I should at least get people thinking about whether it’s something they’d want to work on. (Renewal is at the end of January)

4 Likes

I also asked about the goal as I could help with the monetary costs, no issue – that is easy.

But about the website code itself - is the code on GitHub (or similar) already? Is it about helping with code reviews?

I also have too many projects, but I think wkstats is useful enough, so I could see myself spending some time helping with it – but I’m not sure I have a clear picture of the current situation.

1 Like

The code isn’t published anywhere, though I could do so. At the time, I was experimentally building a lightweight single-page app (SPA) framework because I don’t like being tied to rapidly evolving frameworks with tons of dependencies. But if I was starting over, I would maybe consider using Svelte/SvelteKit now that it has matured quite a bit. Svelte is a far lighter and less opinionated framework than most. And it’s nice for compartmentalizing development.

Anyway, the existing code is fairly clean, though maybe the versioning system is a little cumbersome. It uses WaniKani Open Framework’s cache system to load pages modularly as you navigate. Each page contains its own html, css, and js in a single file, like many single-page-app frameworks.

I’ll try to set aside some time to load it into github or something. Or maybe even just zip it and post a link.

3 Likes

Either way works, I think the important part is — unless you want to keep this private — to get it published. Then people can fork it, send pull requests, etc. — I think this is the best way to keep it alive.

Ages ago, I would have cared about the technology being used, but now, a tool is a tool. So I wouldn’t worry about how it is written and using which framework — if it works, it works, and that’s the main thing.

2 Likes

Just to chip in: I do use wkstats (mostly to stay motivated as you said) but I wouldn’t mind if it was under different domain/subdomain (maybe some cheaper one; $18 seems excessive .ovh is like $3 for renewal :slight_smile: )

It would be awesome if the code was published (so maybe it would be easier to contribute, though I’m not very fluent/liking JS much; but at least for ā€œposteriorityā€).

Hosting wise - if it’s just a html+JS maybe use github pages?

I use [Userscript] WKStats Projections Page script to view the information – extremely helpful! :slight_smile:

What would the handoff process look like? I don’t work with web apps very much so I’m not too familiar with the process. Uploading it to GitHub and making it open source could be the way to go while the domain is kept active.

One thing I’d recommend is moving the domain name over to Cloudflare Domains, since you only pay the price required by the IANA, without them taking any cut for themselves (forever, price only changes if IANA changes the price, no ā€œfirst year, but then doubleā€ surprises).

.com domains cost about $10 on Cloudflare. Hosting could also be moved over to Cloudflare and would probably never hit their generous free tier limits with server-side code (if it’s just a statically-served SPA, there’s no limit: static assets are completely free).
I also have a VPS that I use for various of my own apps, and you’re free to use it as well for hosting if it would fit better (uses Dokploy, and you can either supply it with a Docker image, or have it build the code on each commit).

As for the development, I think I can help. I’ve been doing web dev and design for the past 2 years, mostly with SvelteKit. Granted, I have quite a bit on my plate recently, but I’ll hopefully have plenty of time for extra projects after the next couple of weeks.

wkstats is definitely something I love using and would love maintaining as well!

… I’ve been eying making a native Android app for wkstats for a while, but never got around to it…

2 Likes