// ==UserScript==
// @name Araigoshi's Wanikani Stage Breakdown
// @namespace https://www.wanikani.com
// @description Show SRS and leech breakdown on dashboard, with ability to expand and view detailed lists.
// @author araigoshi
// @version 1.2.1
// @match https://www.wanikani.com/*
// @license MIT
// @grant none
// ==/UserScript==
(async function () {
'use strict';
/* global wkof */
if (!window.wkof) {
let response = confirm('Dashboard Stage Breakdown 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;
}
const SCRIPT_ID = 'araistages';
const STAGE_NAMES = {
0: 'Unlearned',
1: 'Apprentice I',
2: 'Apprentice II',
3: 'Apprentice III',
4: 'Apprentice IV',
5: 'Guru I',
6: 'Guru II',
7: 'Master',
8: 'Enlightened',
9: 'Burned',
};
const TYPE_LABELS = {
'radical': 'Radical',
'kanji': 'Kanji',
'vocabulary': 'Vocab',
'kana_vocabulary': 'Vocab'
};
const ROMAN_NUMERALS = [0, 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
const CSS_TEXT =
`
.dashboard section.srs-progress .srs-progress-details-group {
display: flex;
margin: 0;
list-style-type: none;
gap: 0.3em;
justify-content: center;
padding-top: var(--spacing-xtight);
font-size: var(--font-size-xsmall);
color: var(--color-text);
}
.araistages-items-compact {
.srs-progress__subject-types {
display: none !important;
}
}
.araistages-items-expanded {
.araistages-types-group {
display: none !important;
}
}
.araistages-hide-leeches-with-details {
.srs-progress__stage--apprentice, .srs-progress__stage--guru {
.leeches-group {
display: none !important;
}
}
}
.dashboard section.srs-progress .srs-progress-details-group:has(+ .leeches-group:hover),
.dashboard section.srs-progress .srs-progress-details-group:has(+ .srs-progress-details-group + .leeches-group:hover)
{
.group-item-leeches {
display: initial;
}
.group-item-value {
display: none;
}
}
.dashboard section.srs-progress {
li.grouped-list {
padding: var(--spacing-xtight) 0.3em;
border-radius: var(--border-radius-tight);
background-color: var(--color-srs-progress-subject-type-background, white);
flex-grow: 1;
flex-basis: 0;
display: flex;
align-items: center;
justify-content: space-between;
&:first-child {
padding-left: var(--spacing-xtight);
}
&:last-child {
padding-right: var(--spacing-xtight);
}
&:hover {
background-color: #f88;
}
.group-item-label {
flex-grow: 1;
}
.group-item-value {
font-weight: bold;
}
&:hover .group-item-value {
display: none;
}
.group-item-leeches {
font-weight: bold;
display: none;
}
&:hover .group-item-leeches {
display: initial;
}
}
.srs-progress__stage--apprentice li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-apprentice); } }
.srs-progress__stage--guru li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-guru); } }
.srs-progress__stage--master li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-master); } }
.srs-progress__stage--enlightened li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-enlightened); } }
}
#araistages-info-dialog {
width: 800px;
max-height: 80vh;
flex-direction: column;
border-radius: var(--border-radius-normal);
padding: var(--spacing-tight);
.close-button {
border-radius: var(--border-radius-normal);
border-style: solid;
}
&[open] {
display: flex;
}
header {
display: flex;
gap: 2em;
align-items: center;
justify-content: space-between;
margin: var(--spacing-xtight) 0;
h2 {
font-size: var(--font-size-xlarge);
}
button {
border-style: solid;
border-radius: var(--border-radius-normal);
font-size: 20px;
padding: 5px;
font-weight: bold;
}
}
.table-wrapper {
flex-grow: 1;
overflow: auto;
}
table {
width: 100%;
border-radius: var(--border-radius-normal);
a {
color: var(--color-link);
text-decoration: none;
}
thead {
th {
background-color: var(--color-character-grid-header-background, #d5d5d5);
}
th:first-child {
border-radius: var(--border-radius-normal) 0 0 0;
}
th:last-child {
border-radius: 0 var(--border-radius-normal) 0 0;
}
}
tbody,
tbody a {
color: var(--color-character-text);
}
th {
text-align: left;
font-variant: small-caps;
}
th, td {
padding: 0.7em 1em;
}
wk-character-image {
width: 14px;
display: inline-block;
}
.row-kanji td {
background-color: var(--color-kanji);
}
.row-kana_vocabulary td, .row-vocabulary td {
background-color: var(--color-vocabulary);
}
.row-radical td {
background-color: var(--color-radical);
}
tbody tr:last-child td:first-child {
border-radius: 0 0 0 var(--border-radius-normal);
}
tbody tr:last-child td:last-child {
border-radius: 0 0 var(--border-radius-normal) 0;
}
}
.options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2em;
margin: var(--spacing-xtight) 0;
select, input {
width: fit-content;
}
.option {
display: flex;
align-items: baseline;
gap: 0.5em;
}
}
}`
const state = {
dialog: createDialog(),
itemsBySrs: null,
};
window.araistages_state = state;
const requiredWkofModules = 'Menu,ItemData,Settings';
wkof.include(requiredWkofModules);
await wkof.ready(requiredWkofModules);
wkof.Settings.load(SCRIPT_ID, {
compactItemTypes: true,
leechThreshold: 1,
alwaysShowTotalLeeches: false,
useRMSForLeechScore: false,
forceClearCache: false,
tableSortOrder: 'wanikani',
tablePageSize: 20,
tableGroupType: 'all',
tableGroup: 'all',
tableItemType: 'all',
});
loadStylesheet();
await loadData();
document.documentElement.addEventListener("turbo:load", async (evt) => {
const path = new URL(evt.detail.url).pathname;
if (shouldLoadOnPage(path)) {
await loadData(true);
}
renderer(path);
});
renderer(document.location.pathname);
function loadStylesheet() {
if (document.getElementById('araistages-styles') === null) {
let styleSheet = document.createElement('style');
styleSheet.id = 'araistages-styles';
styleSheet.textContent = CSS_TEXT;
document.body.appendChild(styleSheet);
}
}
function shouldLoadOnPage(path) {
return path === '/dashboard' || path === '/';
}
function renderer(path) {
if (shouldLoadOnPage(path)) {
setTimeout(() => updatePage(state.itemsBySrs), 0);
}
}
async function loadData(clearCache) {
if (clearCache && wkof.settings[SCRIPT_ID]?.forceClearCache) {
wkof.Apiv2.clear_cache();
}
const allItems = await wkof.ItemData.get_items({
wk_items: {
options: {
review_statistics: true,
assignments: true
}
}
});
const filteredItems = allItems.filter(item => item?.assignments?.srs_stage > 0);
state.itemsBySrs = mapItemsToSrs(filteredItems);
}
function insertMenu() {
wkof.Menu.insert_script_link({
name: `${SCRIPT_ID}_settings_open`,
submenu: 'Settings',
title: "Araigoshi's Dashboard Stage Breakdown",
on_click() {
new wkof.Settings({
script_id: SCRIPT_ID,
title: "Araigoshi's Dashboard Stage Breakdown",
content: {
compactItemTypes: {
type: 'checkbox',
label: 'Compact Item Types',
hover_tip: 'Display all item types on one line. Turn off to use the Wanikani default item type breakdown.'
},
alwaysShowTotalLeeches: {
type: 'checkbox',
label: 'Always show total leeches',
hover_tip: 'Display the total leeches as its own row, even for apprentice and guru',
},
useRMSForLeechScore: {
type: 'checkbox',
label: 'Use the Root-Mean-Square method for getting final leech score',
hover_tip: 'Takes the Root-Mean-Square of the reading leech score and meaning leech score instead of the maximum',
},
forceClearCache: {
type: 'checkbox',
label: 'Clear Cache On Navigation',
html: 'Useful if your review sessions last under a minute'
},
leechThreshold: {
type: 'number',
label: 'Leech Threshold',
min: 1,
hover_tip: 'How high the leech score needs to be to consider an item a leech.'
},
leechNote: {
type: 'html',
html: 'Note: Leeches will be recalculated after a page refresh.'
}
},
on_save() {
setStagesClasses();
}
}).open()
}
});
}
const sortFunctions = {
wanikani(itemA, itemB) {
const levelSort = itemA.data.level - itemB.data.level;
return levelSort != 0 ? levelSort : itemA.data.lesson_position - itemB.data.lesson_position;
},
nextReview(itemA, itemB) {
return Date.parse(itemA.assignments.available_at) - Date.parse(itemB.assignments.available_at);
},
leechScore(itemA, itemB) {
return getLeechScore(itemB) - getLeechScore(itemA);
}
}
function mapItemsToSrs(items) {
const itemsBySrs = [1, 2, 3, 4, 5, 6, 7, 8, 9].reduce((result, srs) => {
result[srs] = {
totals: {
all: 0,
radical: 0,
vocabulary: 0,
kanji: 0,
},
leeches: {
all: 0,
radical: 0,
vocabulary: 0,
kanji: 0,
},
items: [],
};
return result;
}, {});
for (let item of items) {
const srsStage = item.assignments.srs_stage;
let subjectType = item.assignments.subject_type;
if (subjectType === 'kana_vocabulary') {
subjectType = 'vocabulary'
}
itemsBySrs[srsStage].totals[subjectType]++;
itemsBySrs[srsStage].totals.all++;
if (isLeech(item)) {
itemsBySrs[srsStage].leeches[subjectType]++;
itemsBySrs[srsStage].leeches.all++;
}
itemsBySrs[srsStage].items.push(item);
}
return itemsBySrs;
}
function rmsOfPair(num1, num2) {
return Math.sqrt((Math.pow(num1, 2) + Math.pow(num2, 2)) / 2);
}
function getLeechScore(item) {
if (item.review_statistics === undefined) {
return 0;
}
const reviewStats = item.review_statistics;
const meaningScore = calculateLeechScore(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak);
const readingScore = calculateLeechScore(reviewStats.reading_incorrect, reviewStats.reading_current_streak);
return wkof.settings[SCRIPT_ID]?.useRMSForLeechScore ? rmsOfPair(meaningScore, readingScore) : Math.max(meaningScore, readingScore);
}
function isLeech(item) {
return getLeechScore(item) > (wkof.settings[SCRIPT_ID]?.leechThreshold || 1);
}
function calculateLeechScore(incorrect, currentStreak) {
return incorrect / Math.pow((currentStreak || 0.5), 1.5);
}
function stagesForSection(section) {
switch (section) {
case 'apprentice':
return [1, 2, 3, 4];
case 'guru':
return [5, 6];
case 'master':
return [7];
case 'enlightened':
return [8];
case 'burned':
return [9];
}
}
function setStagesClasses() {
const el = document.querySelector('.dashboard__srs-progress');
if(!el) {
// Settings accessed from non-dashboard page
return;
}
el.classList.remove('araistages-items-expanded', 'araistages-items-compact', 'araistages-always-leeches')
const itemTypesCls = wkof.settings[SCRIPT_ID]?.compactItemTypes ? 'compact' : 'expanded';
el.classList.toggle('araistages-hide-leeches-with-details', !wkof.settings[SCRIPT_ID]?.alwaysShowTotalLeeches);
el.classList.add(`araistages-items-${itemTypesCls}`);
}
function updatePage(itemsBySrs) {
insertMenu();
setStagesClasses();
for (var section of ['apprentice', 'guru', 'master', 'enlightened']) {
addTypes(section, itemsBySrs);
addSubStages(section, itemsBySrs);
addTotalLeeches(section, itemsBySrs);
}
addTypes('burned', itemsBySrs);
}
function makeGroupedListItem(labelText, valueCount, leechesCount, dataProperties) {
const li = document.createElement('li');
li.classList.add('grouped-list');
for (const property of Object.keys(dataProperties)) {
li.dataset[property] = dataProperties[property];
}
const label = document.createElement('span');
label.classList.add('group-item-label');
label.textContent = labelText;
li.appendChild(label);
const value = document.createElement('span');
value.classList.add('group-item-value');
value.textContent = valueCount;
li.appendChild(value);
const leeches = document.createElement('span');
leeches.textContent = leechesCount;
leeches.classList.add('group-item-leeches');
li.appendChild(leeches);
li.addEventListener('click', handleGroupClick);
return li;
}
function selectorForSection(srsSectionId) {
return `.srs-progress__stage--${srsSectionId}`;
}
function addSubStages(srsSectionId, itemsBySrs) {
const stages = stagesForSection(srsSectionId);
if (stages.length <= 1) {
return;
}
const items = stages.map((srs, i) => makeGroupedListItem(
ROMAN_NUMERALS[i + 1],
itemsBySrs[srs].totals.all,
itemsBySrs[srs].leeches.all,
{ groupType: 'stage', group: srs, itemType: 'all' }
));
document.querySelector(selectorForSection(srsSectionId)).appendChild(makeGroupList(items, 'stages-group'));
}
function addTotalLeeches(srsSectionId, itemsBySrs) {
const totalLeechCount = sumAll(itemsBySrs, srsSectionId, 'leeches', 'all');
document.querySelector(selectorForSection(srsSectionId)).appendChild(
makeGroupList(
[makeGroupedListItem(
'Leeches', totalLeechCount,
totalLeechCount,
{ groupType: 'section', group: srsSectionId, itemType: 'all' }
)],
'leeches-group'
),
);
}
function sumAll(itemsBySrs, srsSectionId, group, field) {
return stagesForSection(srsSectionId).reduce((n, stage) => n + itemsBySrs[stage][group][field], 0);
}
function makeTypeListItem(itemsBySrs, srsSectionId, abbr, itemType) {
return makeGroupedListItem(
abbr,
sumAll(itemsBySrs, srsSectionId, 'totals', itemType),
sumAll(itemsBySrs, srsSectionId, 'leeches', itemType),
{ groupType: 'section', group: srsSectionId, itemType }
);
}
function makeGroupList(items, className) {
const wrapper = document.createElement('ol');
wrapper.classList.add('srs-progress-details-group', className);
for (const item of items) {
wrapper.appendChild(item);
};
return wrapper;
}
function addTypes(srsSectionId, itemsBySrs) {
const items = [
makeTypeListItem(itemsBySrs, srsSectionId, 'R', 'radical'),
makeTypeListItem(itemsBySrs, srsSectionId, 'K', 'kanji'),
makeTypeListItem(itemsBySrs, srsSectionId, 'V', 'vocabulary'),
];
document.querySelector(selectorForSection(srsSectionId)).appendChild(makeGroupList(items, 'araistages-types-group'));
}
function renderSubject(item) {
if (item.object !== 'radical') {
return item.data.characters;
}
const primaryMeaning = item.data.meanings.find(meaning => meaning.primary === true).meaning;
if (item.data.characters !== null) {
return `${item.data.characters} (${primaryMeaning})`;
} else {
const imageUrl = item.data.character_images.find(image => image.content_type === 'image/svg+xml').url;
return `<wk-character-image class="radical-image" src="${imageUrl}" aria-label=${primaryMeaning} alt="${primaryMeaning} radical"></wk-character-image> (${primaryMeaning})`;
}
}
function rerenderDialogTable(groupType, group, itemType) {
const resolvedGroupType = groupType ?? wkof.settings[SCRIPT_ID].tableGroupType;
const resolvedGroup = group ?? wkof.settings[SCRIPT_ID].tableGroup;
const resolvedItemType = itemType ?? wkof.settings[SCRIPT_ID].tableItemType;
const items = getItems(resolvedGroupType, resolvedGroup, resolvedItemType);
const tableRows = items.map(item => {
const leechScore = new Intl.NumberFormat().format(getLeechScore(item));
const assignAvailable = item.assignments.available_at;
const nextReview = assignAvailable === null ? 'Never' : new Date(item.assignments.available_at)
.toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short'
});
const subject = renderSubject(item);
return `<tr class="row-${item.object}">
<td><a href="${item.data.document_url}">${subject}</a></td>
<td>${TYPE_LABELS[item.object]}</td>
<td>${STAGE_NAMES[item.assignments.srs_stage]}</td>
<td>${item.data.level}</td>
<td>${leechScore}</td>
<td>${nextReview}</td>
</tr>`;
});
state.dialog.querySelector('tbody').innerHTML = tableRows.join('');
}
function createDialog() {
const dialogEl = document.createElement('dialog');
dialogEl.innerHTML = `
<header>
<h2>Item Stage Breakdown</h2>
<button class="close-button">X</button>
</header>
<div class="options">
<div class="option">
<label for="araistages-table-group">Stage</label>
<select id="araistages-table-group">
<option selected value="all-all">All</option>
<hr>
<option value="section-apprentice">Apprentice</option>
<option value="stage-1">-- Apprentice I</option>
<option value="stage-2">-- Apprentice II</option>
<option value="stage-3">-- Apprentice III</option>
<option value="stage-4">-- Apprentice IV</option>
<hr>
<option value="section-guru">Guru</option>
<option value="stage-5">-- Guru I</option>
<option value="stage-6">-- Guru II</option>
<hr>
<option value="section-master">Master</option>
<hr>
<option value="section-enlightened">Enlightened</option>
<hr>
<option value="section-burned">Burned</option>
</select>
</div>
<div class="option">
<label for="araistages-table-type">Type</label>
<select id="araistages-table-type">
<option value="all">All</option>
<option value="radical">- Radicals</option>
<option value="kanji">- Kanji</option>
<option value="vocabulary">- Vocab</option>
</select>
</div>
<div class="option">
<label for="araistages-sort-order">Sort</label>
<select id="araistages-sort-order">
<option selected value="wanikani">WK Order</option>
<option value="nextReview">Next Review</option>
<option value="leechScore">Leech Score</option>
</select>
</div>
<div class="option">
<label for="araistages-page-size">Max Items</label>
<select id="araistages-page-size">
<option selected value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="9999">All</option>
</select>
</div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th>Stage</th>
<th>Level</th>
<th>Leech Score</th>
<th>Next Review</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
`;
dialogEl.id = 'araistages-info-dialog';
dialogEl.addEventListener('change', (evt) => {
console.log(evt);
});
dialogEl.querySelector('.close-button').addEventListener('click', () => {
dialogEl.close();
});
dialogEl.querySelector('#araistages-sort-order').addEventListener('change', (evt) => {
wkof.settings[SCRIPT_ID].tableSortOrder = evt.target.value;
rerenderDialogTable();
});
dialogEl.querySelector('#araistages-page-size').addEventListener('change', (evt) => {
wkof.settings[SCRIPT_ID].tablePageSize = parseInt(evt.target.value, 10);
rerenderDialogTable();
});
dialogEl.querySelector('#araistages-table-group').addEventListener('change', (evt) => {
const [groupType, group] = evt.target.value.split('-');
wkof.settings[SCRIPT_ID].tableGroupType = groupType;
wkof.settings[SCRIPT_ID].tableGroup = group;
rerenderDialogTable(groupType, group);
});
dialogEl.querySelector('#araistages-table-type').addEventListener('change', (evt) => {
wkof.settings[SCRIPT_ID].tableItemType = evt.target.value;
rerenderDialogTable();
});
document.body.appendChild(dialogEl);
return dialogEl;
}
function handleGroupClick(evt) {
const data = evt.currentTarget.dataset;
showDialog(data.groupType, data.group, data.itemType);
}
function getItems(group, option, itemType) {
let stages = Object.keys(state.itemsBySrs).map(x => parseInt(x, 10));
if(group === 'stage') {
stages = [parseInt(option, 10)];
}
if(group === 'section') {
stages = stagesForSection(option);
}
let items = [];
for (const stage of stages) {
items = items.concat(state.itemsBySrs[stage].items);
}
if(itemType !== 'all') {
items = items.filter(item => item.object === itemType || (itemType === 'vocabulary' && item.object === 'kana_vocabulary'));
}
const pageSize = wkof.settings[SCRIPT_ID]?.tablePageSize || 20;
items.sort(sortFunctions[wkof.settings[SCRIPT_ID].tableSortOrder]);
return items.slice(0, pageSize);
}
function showDialog(groupType, group, itemType) {
rerenderDialogTable(groupType, group, itemType);
state.dialog.querySelector('#araistages-table-group').value = `${groupType}-${group}`;
state.dialog.querySelector('#araistages-table-type').value = itemType;
wkof.settings[SCRIPT_ID].tableGroupType = groupType;
wkof.settings[SCRIPT_ID].tableGroup = group;
wkof.settings[SCRIPT_ID].tableItemType = itemType;
state.dialog.showModal();
}
})();