WK Distraction Blocker

Block certain websites until you do your reviews

// ==UserScript==
// @name         WK Distraction Blocker
// @namespace    http://tampermonkey.net/
// @version      0.3.2
// @description  Block certain websites until you do your reviews
// @author       Gorbit99
// @match        */*
// @icon         https://www.google.com/s2/favicons?domain=wanikani.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_getResourceText
// @run-at       document-end
// @require      https://greasyfork.org/scripts/441792-cidwwa/code/CIDWWA.js?version=1060343
// @license      MIT
// ==/UserScript==

/*global $, GM_getValue, GM_setValue, GM_getResourceText*/

(function() {
  "use strict";

  let blockButton;
  let progressIndicator;
  let previousItem;

  const defaultSettings = {
    condition: "reviews-left",
    reviewsLeft: 0,
    dailyLimit: 50,
    goalDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
    blockedSites: [
      "youtube.com",
    ],
    apiToken: "",
  };

  let settings;

  function loadSettings() {
    settings = JSON.parse(GM_getValue("settings", "{}"));
    for (let key in defaultSettings) {
      settings[key] ??= defaultSettings[key];
    }
  }

  loadSettings();

  settings.goalDate = new Date(settings.goalDate);

  if (location.host === "www.wanikani.com"
    || location.host === "preview.wanikani.com") {
    if (document.querySelector(".sitemap__avatar")) {
      handleWK();
    } else {
      insertProgressCounter();
      updateProgressCounter();
      hookWKEvents();
    }
  } else {
    checkIfShouldBlock();
  }

  function handleWK() {
    createWKModal();
    injectWKStyle();
    fillWKValues();
    insertProgressCounter();
    updateProgressCounter();
    hookWKEvents();
  }

  function createWKModal() {
    blockButton = window.createButton({
      englishText: "Block",
      japaneseText: "ブロック",
      color: "#a00",
      hoverColor: "#c00",
    });

    const modal = window.createModal({
      title: "Distraction Blocker",
    });

    blockButton.onTurnOn(() => modal.open());
    blockButton.onTurnOff(() => modal.close());
    modal.onClose(() => blockButton.setState(false));

    modal.setContent(`
      <div class="[ dblocker ][ settings ]">
        <h5>Blocked sites:</h5>
        <textarea id="dblocker-blocked" class="[ dblocker ][ blocked-sites ]">
        </textarea>
        <h5>Conditions:</h5>
        <div class="[ dblocker ][ condition-group ]">
          <div class="[ dblocker ][ condition-container ]">
            <input type="radio" 
              name="dblocker-condition" 
              value="reviews-left"
              id="dblocker-rleft"
            />
            <label for="dblocker-rleft">
              Until at most
              <input type="numeric" id="dblocker-rleft-number"/>
              reviews remain
            </label>
          </div>

          <div class="[ dblocker ][ condition-container ]">
            <input type="radio" 
              name="dblocker-condition" 
              value="daily-limit" 
              id="dblocker-dlimit"
            />
            <label for="dblocker-dlimit">
              Until 
              <input type="numeric" id="dblocker-dlimit-number"/>
              reviews were completed
            </label>
          </div>

          <div class="[ dblocker ][ condition-container ]">
            <input type="radio" 
              name="dblocker-condition" 
              value="goal" 
              id="dblocker-goal"
            />
            <label for="dblocker-goal">
              I want to get rid of my reviews by 
              <input 
                type="date" 
                id="dblocker-goal-date" 
                class="[ dblocker ][ date ]"
              />
              (at least 
              <span class="[ dblocker ][ goal-average ]">0</span>
              daily reviews)
            </label>
          </div>
        </div>
        <h5>API Token:</h5>
        <input id="dblocker-api-token" />
      </div>
    `);
  }

  function injectWKStyle() {
    const styleElem = document.createElement("style");
    styleElem.innerHTML = `
    .dblocker.settings {
      display: flex;
      flex-direction: column;
      width: 400px;
    }

    .dblocker.condition-group {
      border: 1px solid black;
      border-radius: 10px;
      width: 100%;
      padding: 5%;
      box-sizing: border-box;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .dblocker.condition-container {
      display: flex;
      flex-direction: row;
      gap: 10px;
      align-items: baseline;
    }

    .dblocker.condition-container > label > input {
      width: 5ch;
    }

    .dblocker.condition-container > label > .date {
      width: 15ch;
    }

    .dblocker.blocked-sites {
      width: 100%;
      resize: vertical;
      box-sizing: border-box;
      height: 10em;
    }

    .dblocker.progress-indicator {
      margin-left: 20px;
      color: #fff;
      opacity: 0.5;
    }
    `;

    document.head.append(styleElem);
  }

  function fillWKValues() {
    const blockedSites = document.querySelector("#dblocker-blocked");
    blockedSites.value = settings.blockedSites.join("\n");
    const conditions = document.querySelectorAll("[name=dblocker-condition]");
    [...conditions]
      .find((condition) => condition.value == settings.condition)
      .checked = true;

    const rleftNumber = document.querySelector("#dblocker-rleft-number");
    rleftNumber.value = settings.reviewsLeft;
    const dlimitNumber = document.querySelector("#dblocker-dlimit-number");
    dlimitNumber.value = settings.dailyLimit;

    const goalDate = document.querySelector("#dblocker-goal-date");
    goalDate.value = settings.goalDate.toISOString().split("T")[0];
    const minDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 2);
    goalDate.min = minDate.toISOString().split("T")[0];

    const apiToken = document.querySelector("#dblocker-api-token");
    apiToken.value = settings.apiToken;

    updateGoalAverage();
  }

  async function updateGoalAverage() {
    const goalNumber = document.querySelector(".goal-average");
    const reviewsPerDay = await getDailyGoalAverage();
    goalNumber.textContent = reviewsPerDay;
  }

  async function getDailyGoalAverage() {
    const dateStr = settings.goalDate.toISOString();

    const assignmentResponse =
      await makeApiRequest(`assignments?available_before=${dateStr}`);
    const reviewsByThen = assignmentResponse.total_count;

    const daysUntil =
      Math.floor((settings.goalDate - new Date()) / 1000 / 24 / 60 / 60);

    const reviewsPerDay = Math.round(reviewsByThen / daysUntil);
    return reviewsPerDay;
  }

  function hookWKEvents() {
    if (document.querySelector(".sitemap__avatar")) {
      const blockedSites = document.querySelector("#dblocker-blocked");
      blockedSites.addEventListener("change", () => {
        settings.blockedSites = blockedSites.value.split("\n");
        saveSettings();
      });
      const conditions = document.querySelectorAll("[name=dblocker-condition]");
      [...conditions]
        .forEach((condition) => condition.addEventListener("change", () => {
          settings.condition = condition.value;
          saveSettings();
        }));

      const rleftNumber = document.querySelector("#dblocker-rleft-number");
      rleftNumber.addEventListener("change", () => {
        settings.reviewsLeft = parseInt(rleftNumber.value);
        if (isNaN(settings.reviewsLeft)) {
          rleftNumber.value = "0";
          settings.reviewsLeft = 0;
        }
        saveSettings();
      });
      const dlimitNumber = document.querySelector("#dblocker-dlimit-number");
      dlimitNumber.addEventListener("change", () => {
        settings.dailyLimit = parseInt(dlimitNumber.value);
        if (isNaN(settings.dailyLimit)) {
          dlimitNumber.value = "0";
          settings.dailyLimit = 0;
        }
        saveSettings();
      });
      const goalDate = document.querySelector("#dblocker-goal-date");
      goalDate.addEventListener("change", () => {
        settings.goalDate = new Date(goalDate.value);
        saveSettings();
        updateGoalAverage();
      });

      const apiToken = document.querySelector("#dblocker-api-token");
      apiToken.addEventListener("change", () => {
        settings.apiToken = apiToken.value;
        saveSettings();
      });
    } else if (location.href.endsWith("review/session")) {
      $.jStorage.listenKeyChange("currentItem", () => {
        if (!previousItem) {
          previousItem = $.jStorage.get("currentItem");
          return;
        }
        let prefix = "r";
        if (previousItem.type === "Kanji") {
          prefix = "k";
        } else if (previousItem.type === "Vocabulary") {
          prefix = "v";
        }
        const jStorageId = prefix + previousItem.id;
        if ($.jStorage.get(jStorageId) === null) {
          updateProgressCounter();
        }
      });
    }
  }

  function saveSettings() {
    GM_setValue("settings", JSON.stringify(settings));
    updateProgressCounter();
  }

  function insertProgressCounter() {
    if (blockButton) {
      progressIndicator = blockButton.attachSubtext();
    } else if (location.href.endsWith("review/session")) {
      const statsContainer = document.querySelector("#stats");

      statsContainer.insertAdjacentHTML("afterbegin", `
        <span class="[ dblocker ][ progress-indicator-container ]">
          Block:
          <span class="[ dblocker ][ progress-indicator ]"></span>
        </span>
      `);
      progressIndicator = statsContainer.querySelector(".progress-indicator");
    }
  }

  async function updateProgressCounter() {
    const goal = await getConditionGoal();
    const progress = await getConditionProgress();

    progressIndicator.innerHTML = `${progress} / ${goal}`;
  }

  async function checkIfShouldBlock() {
    if (!settings.blockedSites.find((site) =>
      site === "*" || location.host.includes(site))
    ) {
      return;
    }

    const goal = await getConditionGoal();
    const progress = await getConditionProgress();
    if (goal > progress) {
      block();
    }
  }

  async function getConditionGoal() {
    switch (settings.condition) {
      case "reviews-left":
        return getReviewsLeftGoal();
      case "daily-limit":
        return getDailyLimitGoal();
      case "goal":
        return getDecreaseGoal();
    }
  }

  async function getConditionProgress() {
    switch (settings.condition) {
      case "reviews-left":
        return getReviewsLeftProgress();
      case "daily-limit":
        return getDailyLimitProgress();
      case "goal":
        return getDecreaseProgress();
    }
  }

  async function getReviewsLeftGoal() {
    const reviewData =
      await makeApiRequest("assignments?immediately_available_for_review=true");
    return reviewData.total_count;
  }

  async function getDailyLimitGoal() {
    return settings.dailyLimit;
  }

  async function getDecreaseGoal() {
    return await getDailyGoalAverage();
  }

  async function getReviewsLeftProgress() {
    return settings.reviewsLeft;
  }

  async function getDailyLimitProgress() {
    return getReviewsFromToday();
  }

  async function getDecreaseProgress() {
    return getReviewsFromToday();
  }

  async function getReviewsFromToday() {
    const dayStart = new Date();
    dayStart.setHours(0);
    dayStart.setMinutes(0);
    dayStart.setSeconds(0);
    dayStart.setMilliseconds(0);
    const reviewData =
      await makeApiRequest(`reviews?updated_after=${dayStart.toISOString()}`);
    return reviewData.total_count;
  }

  function block() {
    location.href = "https://wanikani.com/review/session";
  }

  async function makeApiRequest(endpoint) {
    const res = await fetch(`https://api.wanikani.com/v2/${endpoint}`, {
      headers: {
        "Authorization": `Bearer ${settings.apiToken}`,
      }
    });
    return await res.json();
  }
})();