Araigoshi's Wanikani Stage Breakdown

Show SRS and leech breakdown on dashboard, with ability to expand and view detailed lists.

// ==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();
  }
})();