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.
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.)
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)
I was previously doing some crazy injection and script overwriting that really only worked properly in Firefox because of the
beforescriptexecuteevent. ā©ļø
Iām bookmarking your post so that Someday⢠I can update the site.
![]()
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 ![]()
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.
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.
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.
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)
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.
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.
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.
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
)
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! ![]()
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ā¦


