Wanikani Review Summary

Show a popup with statistics about the review session when returning to the dashboard

  1. // ==UserScript==
  2. // @name Wanikani Review Summary
  3. // @namespace https://tampermonkey.net/
  4. // @version 0.7.3
  5. // @license MIT
  6. // @description Show a popup with statistics about the review session when returning to the dashboard
  7. // @author leohumnew
  8. // @match https://www.wanikani.com/*
  9. // @require https://greasyfork.org/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1417568
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. let eventListenersAdded = false;
  17.  
  18. window.addEventListener("turbo:load", function(e) {
  19. if (e.detail.url === "https://www.wanikani.com/subjects/review") {
  20. setTimeout(runScript, 0);
  21. }
  22. });
  23. if ("https://www.wanikani.com/subjects/review" === (window.Turbo?.session.history.pageLoaded ? window.Turbo.session.history.location.href : (document.readyState === "complete" ? window.location.href : null))) {
  24. runScript();
  25. }
  26. function keyListener(e) {
  27. // If statistics screen is open, set the right arrow key and the escape key to go back to the dashboard
  28. if (document.getElementById("summary-popup") != null) {
  29. switch (e.key) {
  30. case "ArrowRight":
  31. case "Enter":
  32. case "Escape":
  33. e.preventDefault();
  34. document.body.removeEventListener("keydown", keyListener);
  35. if (window.Turbo) window.Turbo.visit("https://www.wanikani.com/dashboard");
  36. else window.location.href = "https://www.wanikani.com/dashboard";
  37. break;
  38. }
  39. }
  40. }
  41.  
  42. // Global variables to hold current question state
  43. let currentQuestionType = "";
  44. let currentSubjectId = 0;
  45. let currentCategory = "";
  46. let currentWord = "";
  47. let currentSRSLevel = -1;
  48.  
  49. async function runScript() {
  50. // Variables to store the statistics
  51. let questionsAnswered = 0;
  52. let itemsCorrect = 0;
  53. let itemsIncorrect = 0;
  54. let meaningCorrect = 0;
  55. let meaningIncorrect = 0;
  56. let readingCorrect = 0;
  57. let readingIncorrect = 0;
  58. let correctHistory = [];
  59. let incorrectEntered = new Map();
  60. let itemsList = []; // Array to store the items reviewed
  61. let quizQueueSRS = [];
  62.  
  63. // Variables for answer time tracking
  64. let startTime = performance.now();
  65. let meaningAnswerTimes = [];
  66. let readingAnswerTimes = [];
  67. let itemTimeMap = new Map(); // Using a map to store time per item ID
  68.  
  69. // Other Variables
  70. let SRSLevelNames = ["Lesson", "Appr. I", "Appr. II", "Appr. III", "Appr. IV", "Guru I", "Guru II", "Master", "Enl.", "Burned", "Error"];
  71. const GRAPH_HEIGHT = 120;
  72.  
  73. // Create style element with popup styles and append it to the document head
  74. let style = document.createElement("style");
  75. style.id = "summary-popup-styles";
  76. style.textContent = ".summary-popup { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9999; color: var(--color-text); background-color: var(--color-wk-panel-content-background, #eee); padding: 50px; overflow-y: auto; font-size: var(--font-size-large); }";
  77. style.textContent += ".summary-popup .wk-icon { vertical-align: bottom; }";
  78. style.textContent += ".summary-popup > a { background-color: transparent; text-decoration: none; text-align: center; margin: 30px 50px; position: absolute; top: 0px; right: 0px; cursor: pointer; padding: 10px; border-radius: 5px; outline: 1px solid var(--color-tertiary, black); color: var(--color-text) } .summary-popup > a:hover { color: var(--color-tertiary, #bbb); }";
  79. style.textContent += ".summary-popup table { border-collapse: collapse; border-radius: 5px; width: 100%; background-color: var(--color-wk-panel-background, #000); } .summary-popup td { border: none; padding: 5px; text-align: center; }";
  80. style.textContent += ".summary-popup h1 { margin-bottom: 10px; font-weight: bold; font-size: var(--font-size-xlarge); } .summary-popup h2 { font-weight: bold; margin-top: 20px; padding: 20px; color: #fff; font-size: var(--font-size-large); border-radius: 5px 5px 0 0; }";
  81. style.textContent += ".summary-popup ul { background-color: var(--color-wk-panel-background, #fff); padding: 5px; border-radius: 0 0 5px 5px; } .summary-popup li { display: inline-block; } .summary-popup li a { display: block; margin: 10px 5px; padding: 10px; color: var(--color-text-dark, #fff); font-size: 1.5rem; height: 2.6rem; border-radius: 5px; text-decoration: none; position: relative; } .summary-popup li a img { filter: invert(var(--img-invert-value, 1)); height: 1em; vertical-align: middle; }";
  82. style.textContent += ".summary-popup .summary-popup__popup { background-color: var(--color-menu, #ddd); color: var(--color-text, #fff); text-decoration: none; padding: 10px; border-radius: 5px; position: fixed; z-index: 9999; display: none; font-size: var(--font-size-medium); box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5); width: max-content; line-height: 1.3; }";
  83. style.textContent += ".summary-popup .summary-popup__popup:after { content: ''; position: absolute; top: -8px; margin-left: -10px; width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-bottom: 10px solid var(--color-menu, #ddd); }";
  84. style.textContent += ".summary-popup .summary-popup__popup--left:after { right: 15px; } .summary-popup .summary-popup__popup--right:after { left: 25px; }";
  85. style.textContent += ".summary-popup .accuracy-graph { position: relative; height: " + (GRAPH_HEIGHT + 42) + "px; width: 100%; background-color: var(--color-wk-panel-background, #fff); padding: 25px 1% 15px 1%; border-radius: 0 0 5px 5px; }";
  86. style.textContent += ".summary-popup .accuracy-graph span { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: var(--font-size-xlarge); color: var(--color-text); }";
  87. style.textContent += ".summary-popup ul .wk-icon { position: absolute; top: -8px; right: -8px; text-align: center; color: white; background-color: var(--color-burned); padding: 3px; border-radius: 50%; border: white solid 1px }";
  88. style.textContent += ".summary-popup ul .incorrect-text { color: var(--color-incorrect, #cc4343); font-size: var(--font-size-small); vertical-align: top; }";
  89. style.textContent += ".summary-tooltip { position: fixed; display: none; padding: 5px 10px; background-color: #333; color: white; border-radius: 5px; z-index: 10000; pointer-events: none; font-size: var(--font-size-medium); }";
  90. if(window.matchMedia('(prefers-color-scheme: dark)').matches) style.textContent += ".summary-popup ul .incorrect-text { filter: brightness(3) }";
  91.  
  92. if(!document.getElementById("summary-popup-styles")) document.head.appendChild(style);
  93.  
  94. // Function to calculate the percentage
  95. function percentage(numerator, denominator) {
  96. if(denominator == 0) return "--";
  97. return Math.round(numerator / denominator * 100) + "%";
  98. }
  99.  
  100. // Function to get quiz queue SRS
  101. async function getQuizQueueSRS() {
  102. let elementArr = document.querySelector("#quiz-queue script[data-quiz-queue-target='subjectIdsWithSRS']");
  103. if(elementArr) {
  104. quizQueueSRS = JSON.parse(elementArr.innerHTML);
  105. quizQueueSRS = quizQueueSRS.subject_ids_with_srs_info;
  106. } else setTimeout(getQuizQueueSRS, 500);
  107. }
  108. getQuizQueueSRS();
  109.  
  110. function injectEndCode() {
  111. // Clear the data-quiz-queue-done-url-value and data-quiz-queue-completion-url-value parameters on #quiz-queue
  112. function get_controller(name) { // Thanks to @rfindley for this function
  113. return Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name);
  114. }
  115. let quizQueueController = get_controller("quiz-queue");
  116. let quizOnDoneReplacement = function() { setTimeout(showStatistics, 0); };
  117. quizQueueController.onDone = quizOnDoneReplacement.bind(quizQueueController);
  118. quizQueueController.quizQueue.onDone = quizQueueController.onDone;
  119. }
  120.  
  121. // Function to create a popup element
  122. function createPopup(content) {
  123. // Create a div element with some styles
  124. let popup = document.createElement("div");
  125. popup.id = "summary-popup";
  126. popup.className = "summary-popup";
  127.  
  128. // Create a close button with some styles and functionality
  129. let closeButton = document.createElement("a");
  130. closeButton.textContent = "Dashboard";
  131. closeButton.href = "https://www.wanikani.com/dashboard";
  132. closeButton.addEventListener('click', function() {
  133. document.body.removeEventListener("keydown", keyListener);
  134. document.getElementById('summary-popup')?.remove();
  135. });
  136.  
  137. // Append the content and the close button to the popup
  138. popup.append(content, closeButton);
  139.  
  140. return popup;
  141. }
  142.  
  143. // Function to create a table element with some data
  144. function createTable(data) {
  145. // Create a table
  146. let table = document.createElement("table");
  147. let row = document.createElement("tr");
  148. let row2 = document.createElement("tr");
  149. let row3 = document.createElement("tr");
  150.  
  151. // Loop through the data array
  152. for (let i = 0; i < data.length; i++) {
  153. // Create table cell elements
  154. let cell = document.createElement("td");
  155. cell.textContent = data[i][0];
  156. cell.style.fontSize = "var(--font-size-xxlarge)";
  157. cell.style.fontWeight = "bold";
  158. cell.style.padding = "25px 0 0 0";
  159. row.appendChild(cell);
  160.  
  161. let cell2 = document.createElement("td");
  162. cell2.textContent = data[i][1];
  163. cell2.style.fontSize = "var(--font-size-small)";
  164. cell2.style.fontStyle = "italic";
  165. cell2.style.color = "var(--color-text-mid, #999)";
  166. cell2.style.padding = "4px 0 10px 0";
  167. row2.appendChild(cell2);
  168.  
  169. let cell3 = document.createElement("td");
  170. cell3.textContent = data[i][2];
  171. cell3.style.fontSize = "var(--font-size-medium)";
  172. cell3.style.paddingBottom = "25px";
  173. row3.appendChild(cell3);
  174. }
  175. // Append the rows to the table
  176. table.append(row, row2, row3);
  177.  
  178. // Return the table element
  179. return table;
  180. }
  181.  
  182. // Function to create summary section
  183. function createSummarySectionTitle(title, icon, bgColor) {
  184. let sectionTitle = document.createElement("h2");
  185. sectionTitle.appendChild(Icons.customIcon(icon));
  186. sectionTitle.innerHTML += " " + title;
  187. sectionTitle.style.backgroundColor = bgColor;
  188. return sectionTitle;
  189. }
  190.  
  191. // Function to create graph
  192. function createGraph(data, canvas, congratulationMessageText, labels) {
  193. let graphWidth = canvas.getBoundingClientRect().width;
  194. canvas.height = GRAPH_HEIGHT + 2;
  195. canvas.width = graphWidth;
  196. let sidesOffset = parseFloat(window.getComputedStyle(document.documentElement).getPropertyValue('--font-size-small')); // Offset to apply to sides so that label text will fit - is applied to sides and bottom
  197. let bottomOffset = sidesOffset * 1.3;
  198. let graphStep = (graphWidth - (sidesOffset * 2)) / (data.length - 1);
  199. let isAllPerfect = true;
  200. let ctx = canvas.getContext("2d");
  201. // Draw background horizontal lines
  202. ctx.beginPath();
  203. if(window.getComputedStyle(document.documentElement).getPropertyValue('--color-text-mid') == "") ctx.strokeStyle = "#aaa";
  204. else ctx.strokeStyle = window.getComputedStyle(document.documentElement).getPropertyValue('--color-wk-panel-content-background');
  205. ctx.lineWidth = 1;
  206. for (let i = 0; i < 4; i++) {
  207. let y = Math.round((GRAPH_HEIGHT - bottomOffset) / 3 * i) + 0.5;
  208. ctx.moveTo(0, y);
  209. ctx.lineTo(graphWidth, y);
  210. }
  211. ctx.stroke();
  212. // Draw graph
  213. ctx.beginPath();
  214. ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  215. ctx.lineWidth = 2;
  216. for (let i = 0; i < data.length; i++) {
  217. let x = graphStep * i + sidesOffset;
  218. let y = (GRAPH_HEIGHT - bottomOffset) * (1 - data[i]) + 1;
  219. if(data[i] != 1) isAllPerfect = false;
  220. // Draw line
  221. if(i == 0) ctx.moveTo(x, y);
  222. else ctx.lineTo(x, y);
  223. }
  224. ctx.stroke();
  225. // Draw labels
  226. if(labels != null) {
  227. let lastLabel = "";
  228. let isDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].includes(labels[0]);
  229. ctx.fillStyle = window.getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  230. ctx.font = window.getComputedStyle(document.documentElement).getPropertyValue('--font-size-small') + " sans-serif";
  231. ctx.textAlign = "center";
  232. for (let i = 0; i < data.length; i++) {
  233. if(labels[i] != lastLabel) {
  234. let x = graphStep * i + sidesOffset;
  235. let y = GRAPH_HEIGHT - 1;
  236. ctx.fillText(labels[i], x, y);
  237. lastLabel = labels[i];
  238. } else if(isDays && i == data.length - 1) {
  239. let x = graphStep * i + sidesOffset;
  240. let y = GRAPH_HEIGHT - 1;
  241. ctx.fillText("Now", x, y);
  242. }
  243. }
  244. }
  245. // Show congratulation message if all perfect
  246. if(isAllPerfect) {
  247. let congratulationMessage = document.createElement("span");
  248. congratulationMessage.textContent = congratulationMessageText;
  249. canvas.parentNode.appendChild(congratulationMessage);
  250. }
  251. }
  252.  
  253. // Function to create a line graph for answer times
  254. function createTimeGraph(data, canvas, titleText) {
  255. if (!data || data.length === 0) return;
  256.  
  257. const parentStyle = getComputedStyle(canvas.parentElement);
  258. const graphWidth = parseFloat(parentStyle.width);
  259. canvas.height = GRAPH_HEIGHT + 42;
  260. canvas.width = graphWidth;
  261.  
  262. let ctx = canvas.getContext("2d");
  263.  
  264. const padding = 30;
  265. const graphContentWidth = graphWidth - padding * 2;
  266. const graphContentHeight = GRAPH_HEIGHT;
  267. const maxTime = Math.ceil(Math.max(...data));
  268. const stepX = data.length > 1 ? graphContentWidth / (data.length - 1) : graphContentWidth;
  269.  
  270. // --- Draw Y-axis labels and grid lines ---
  271. ctx.beginPath();
  272. ctx.strokeStyle = 'rgba(128, 128, 128, 0.5)';
  273. ctx.lineWidth = 1;
  274. ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  275. ctx.font = "12px sans-serif";
  276. ctx.textAlign = "right";
  277. const numGridLines = 4;
  278. for (let i = 0; i <= numGridLines; i++) {
  279. const y = padding + (i * (graphContentHeight / numGridLines));
  280. const labelValue = (maxTime * (1 - i / numGridLines));
  281. const label = (maxTime > 10 ? Math.round(labelValue) : labelValue.toFixed(1)) + 's';
  282. ctx.fillText(label, padding - 5, y + 4);
  283. ctx.moveTo(padding, y);
  284. ctx.lineTo(padding + graphContentWidth, y);
  285. }
  286. ctx.stroke();
  287.  
  288. // --- Draw data line ---
  289. ctx.beginPath();
  290. ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  291. ctx.lineWidth = 2;
  292. ctx.lineJoin = 'round';
  293. ctx.lineCap = 'round';
  294.  
  295. data.forEach((value, i) => {
  296. const x = padding + i * stepX;
  297. const y = padding + graphContentHeight * (1 - (value / maxTime));
  298. if (i === 0) {
  299. ctx.moveTo(x, y);
  300. } else {
  301. ctx.lineTo(x, y);
  302. }
  303. });
  304. ctx.stroke();
  305.  
  306. // --- Draw title ---
  307. ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  308. ctx.font = "bold 14px sans-serif";
  309. ctx.textAlign = "center";
  310. ctx.fillText(titleText, graphWidth / 2, padding - 10);
  311. }
  312.  
  313. // Function to create a bar chart for total time per item with tooltips
  314. function createTimeBarChart(items, canvas, titleText, tooltipEl) {
  315. if (!items || items.length === 0) return;
  316.  
  317. const data = items.map(item => item.totalTime);
  318. const labels = items.map(item => item.characters.url ? '🖼️' : item.characters); // Handle image radicals
  319.  
  320. const parentStyle = getComputedStyle(canvas.parentElement);
  321. const graphWidth = parseFloat(parentStyle.width);
  322. canvas.height = GRAPH_HEIGHT + 60;
  323. canvas.width = graphWidth;
  324.  
  325. let ctx = canvas.getContext("2d");
  326.  
  327. const topPadding = 30;
  328. const sidePadding = 25;
  329. const bottomPadding = 30;
  330. const graphContentWidth = graphWidth - sidePadding * 2;
  331. const graphContentHeight = canvas.height - topPadding - bottomPadding;
  332. const maxTime = Math.ceil(Math.max(...data, 1)); // Avoid maxTime being 0
  333.  
  334. function drawChart() {
  335. ctx.clearRect(0, 0, canvas.width, canvas.height);
  336. // --- Draw Y-axis labels and grid lines ---
  337. ctx.beginPath();
  338. ctx.strokeStyle = 'rgba(128, 128, 128, 0.5)';
  339. ctx.lineWidth = 1;
  340. ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  341. ctx.font = "12px sans-serif";
  342. ctx.textAlign = "right";
  343. const numGridLines = 4;
  344. for (let i = 0; i <= numGridLines; i++) {
  345. const y = topPadding + (i * (graphContentHeight / numGridLines));
  346. const labelValue = (maxTime * (1 - i / numGridLines));
  347. const label = (maxTime > 10 ? Math.round(labelValue) : labelValue.toFixed(1)) + 's';
  348. ctx.fillText(label, sidePadding - 5, y + 4);
  349. ctx.moveTo(sidePadding, y);
  350. ctx.lineTo(sidePadding + graphContentWidth, y);
  351. }
  352. ctx.stroke();
  353.  
  354. // --- Draw Bars ---
  355. const barWidth = graphContentWidth / data.length;
  356.  
  357. data.forEach((value, i) => {
  358. const barHeight = graphContentHeight * (value / maxTime);
  359. const x = sidePadding + i * barWidth;
  360. const y = topPadding + graphContentHeight - barHeight;
  361.  
  362. const itemType = items[i].type;
  363. if (itemType === "Radical") {
  364. ctx.fillStyle = "var(--color-radical, #00aaff)";
  365. } else if (itemType === "Kanji") {
  366. ctx.fillStyle = "var(--color-kanji, #ff00aa)";
  367. } else {
  368. ctx.fillStyle = "var(--color-vocabulary, #aa00ff)";
  369. }
  370. ctx.fillRect(x + barWidth * 0.1, y, barWidth * 0.8, barHeight > 0 ? barHeight : 0);
  371. });
  372.  
  373.  
  374. // --- Draw title ---
  375. ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-text');
  376. ctx.font = "bold 14px sans-serif";
  377. ctx.textAlign = "center";
  378. ctx.fillText(titleText, graphWidth / 2, topPadding - 10);
  379. }
  380. drawChart(); // Initial draw
  381.  
  382. // --- Tooltip Logic ---
  383. canvas.addEventListener('mousemove', (e) => {
  384. const rect = canvas.getBoundingClientRect();
  385. const mouseX = e.clientX - rect.left;
  386. const mouseY = e.clientY - rect.top;
  387.  
  388. const barWidth = graphContentWidth / data.length;
  389. const index = Math.floor((mouseX - sidePadding) / barWidth);
  390.  
  391. if (index >= 0 && index < data.length) {
  392. const item = items[index];
  393. const barHeight = graphContentHeight * (item.totalTime / maxTime);
  394. const barX = sidePadding + index * barWidth;
  395. const barY = topPadding + graphContentHeight - barHeight;
  396.  
  397. // Check if mouse is within the bar's bounds
  398. if (mouseX >= barX && mouseX <= barX + barWidth && mouseY >= barY && mouseY <= topPadding + graphContentHeight) {
  399. const character = item.characters.url ? '🖼️' : item.characters;
  400. tooltipEl.innerHTML = `<span style="font-size: 1.5rem; vertical-align: middle;">${character}</span> ${item.totalTime.toFixed(2)}s`;
  401. tooltipEl.style.left = `${e.pageX + 15}px`;
  402. tooltipEl.style.top = `${e.pageY}px`;
  403. tooltipEl.style.display = 'block';
  404. return;
  405. }
  406. }
  407. tooltipEl.style.display = 'none';
  408. });
  409.  
  410. canvas.addEventListener('mouseout', () => {
  411. tooltipEl.style.display = 'none';
  412. });
  413. }
  414.  
  415.  
  416. // Function to show the statistics when returning to the dashboard
  417. function showStatistics() {
  418. // Check if there are any items reviewed
  419. if (itemsList.length > 0) {
  420. // Create a heading element with some text and styles
  421. let headingText = document.createElement("h1");
  422. headingText.appendChild(Icons.customIcon("check-checked"));
  423. headingText.innerHTML += " Review Summary";
  424. let heading = document.createElement("div");
  425. heading.append(headingText);
  426.  
  427. // Create an unordered list element
  428. let listCorrect = document.createElement("ul");
  429. let listIncorrect = document.createElement("ul");
  430.  
  431. // Loop through the items list array
  432. let srsUpNum = 0;
  433. let typeNum = [0, 0, 0];
  434. for (let i = 0; i < itemsList.length; i++) {
  435. // Create a list item element with the character or image
  436. let listItem = document.createElement("li");
  437. let listItemLink = document.createElement("a");
  438. if(itemsList[i].characters.url == null) listItemLink.textContent = itemsList[i].characters;
  439. else {
  440. let listItemImage = document.createElement("img");
  441. listItemImage.src = itemsList[i].characters.url;
  442. listItemLink.appendChild(listItemImage);
  443. }
  444. if (itemsList[i].type === "Radical") {
  445. listItemLink.style.backgroundColor = "var(--color-radical, #00aaff)";
  446. listItemLink.href = "https://www.wanikani.com/radicals/" + itemsList[i].meanings[0];
  447. } else if (itemsList[i].type === "Kanji") {
  448. listItemLink.style.backgroundColor = "var(--color-kanji, #ff00aa)";
  449. listItemLink.href = "https://www.wanikani.com/kanji/" + itemsList[i].characters;
  450. }
  451. else {
  452. listItemLink.style.backgroundColor = "var(--color-vocabulary, #aa00ff)";
  453. listItemLink.href = "https://www.wanikani.com/vocabulary/" + itemsList[i].characters;
  454. }
  455.  
  456. // Make link open in new tab
  457. listItemLink.target = "_blank";
  458.  
  459. // Badge if burned or if warning
  460. if(itemsList[i].newSRS == 9) {
  461. listItemLink.style.paddingRight = "15px";
  462. listItemLink.appendChild(Icons.customIcon("fire"));
  463. } else if(itemsList[i].isWarning) {
  464. listItemLink.style.paddingRight = "15px";
  465. listItemLink.appendChild(Icons.customIcon("warning"));
  466. }
  467.  
  468. // Create popup with meaning and reading info on hover
  469. let popup = document.createElement("div");
  470. popup.className = "summary-popup__popup";
  471. popup.innerHTML = "Meaning: <strong>" + itemsList[i].meanings.slice(0, 2).join(", ") + "</strong>";
  472. if(itemsList[i].incorrectEntered != null && itemsList[i].incorrectEntered[0].length > 0) popup.innerHTML += '<br><span class="incorrect-text">&nbsp;&nbsp;&nbsp;<strong>X</strong>&nbsp;' + itemsList[i].incorrectEntered[0].join(", ") + "</span>";
  473. if(itemsList[i].type == "Kanji") {
  474. typeNum[1]++;
  475. for (let k = 0; k < itemsList[i].readings.length; k++) { // Nanori, Onyomi, Kunyomi readings if kanji
  476. if (itemsList[i].readings[k] != null) {
  477. let label = "";
  478. switch (k) {
  479. case 0:
  480. label = "Nanori";
  481. break;
  482. case 1:
  483. label = "Onyomi";
  484. break;
  485. case 2:
  486. label = "Kunyomi";
  487. break;
  488. }
  489. popup.innerHTML += "<br>" + label + ": <strong>" + itemsList[i].readings[k].join(", ") + "</strong>";
  490. }
  491. }
  492. }
  493. else if(itemsList[i].type != "Radical" && itemsList[i].readings.length > 0) { // Reading if vocabulary
  494. typeNum[2]++;
  495. popup.innerHTML += "<br>Reading: <strong>" +
  496. itemsList[i].readings.join(", ") +
  497. "</strong>";
  498. } else { // No reading if radical
  499. typeNum[0]++;
  500. }
  501. if(itemsList[i].incorrectEntered != null && itemsList[i].incorrectEntered[1].length > 0) popup.innerHTML += '<br><span class="incorrect-text">&nbsp;&nbsp;&nbsp;<strong>X</strong>&nbsp;' + itemsList[i].incorrectEntered[1].join(", ") + "</span>";
  502. popup.innerHTML += "<br>SRS: " + SRSLevelNames[itemsList[i].oldSRS] + " -> " + SRSLevelNames[itemsList[i].newSRS];
  503.  
  504. popup.style.display = "block";
  505. popup.style.visibility = "hidden";
  506. listItemLink.addEventListener("mouseover", function(e) {
  507. // Position the popup element relative to the parent item element: to the right of the parent unless that would cause the popup to go off the screen
  508. let infoPos = listItemLink.getBoundingClientRect();
  509. let popupPos = popup.getBoundingClientRect();
  510. if (infoPos.left + popupPos.width + 5 > window.innerWidth) {
  511. popup.style.right = window.innerWidth - infoPos.right + "px";
  512. popup.style.removeProperty("left");
  513. popup.className = "summary-popup__popup summary-popup__popup--left";
  514. } else {
  515. popup.style.left = infoPos.left + "px";
  516. popup.style.removeProperty("right");
  517. popup.className = "summary-popup__popup summary-popup__popup--right";
  518. }
  519. popup.style.top = infoPos.bottom + 5 + "px";
  520. popup.style.visibility = "visible";
  521. });
  522.  
  523. listItemLink.addEventListener("mouseout", function(e) {
  524. popup.style.visibility = "hidden";
  525. });
  526. popup.style.visibility = "hidden";
  527.  
  528. // Append the list item to the list
  529. listItemLink.appendChild(popup);
  530. listItem.appendChild(listItemLink);
  531. if (itemsList[i].SRSUp) {
  532. listCorrect.appendChild(listItem);
  533. srsUpNum++;
  534. }
  535. else listIncorrect.appendChild(listItem);
  536. }
  537.  
  538. // Create a header table with main stats
  539. let data = [
  540. [itemsList.length, "R: " + typeNum[0] + " / K: " + typeNum[1] + " / V: " + typeNum[2], "Items Completed"],
  541. [percentage(srsUpNum, itemsList.length), srsUpNum + " out of " + itemsList.length, "Items Correct"],
  542. [percentage(itemsCorrect, questionsAnswered), itemsCorrect + " out of " + questionsAnswered , "Questions Correct"],
  543. [percentage(meaningCorrect, meaningCorrect + meaningIncorrect), meaningCorrect + " out of " + (meaningCorrect + meaningIncorrect), "Meanings Correct"],
  544. [percentage(readingCorrect, readingCorrect + readingIncorrect), readingCorrect + " out of " + (readingCorrect + readingIncorrect), "Readings Correct"]
  545. ];
  546. let table = createTable(data);
  547.  
  548. // Create h2 titles for the lists
  549. let correctTitle = createSummarySectionTitle(srsUpNum + " Items SRS Up", "srs-up", "var(--color-quiz-correct-background, #88cc00)");
  550. let incorrectTitle = createSummarySectionTitle((itemsList.length - srsUpNum) + " Items SRS Down", "srs-down", "var(--color-quiz-incorrect-background, #ff0033)");
  551.  
  552. // Create a graph showing accuracy throughout the session using the correctHistory array, with an average of 3 elements
  553. let graphTitle, graphDiv;
  554. if(itemsList.length > 4) {
  555. graphTitle = createSummarySectionTitle(" Session Accuracy", "chart-line", "var(--color-menu, #777)");
  556. // Graph
  557. graphDiv = document.createElement("div");
  558. graphDiv.classList = "accuracy-graph";
  559. let graph = document.createElement("canvas");
  560. graph.style.width = "100%";
  561. graph.style.height = "100%";
  562. graphDiv.appendChild(graph);
  563. }
  564.  
  565. // Get existing accuracy history array from local storage or create new one, then append the current accuracy to it and store it again
  566. let accuracyArray = JSON.parse(localStorage.getItem("WKSummaryAccuracyHistory")) || [];
  567. if(accuracyArray != [] && !Array.isArray(accuracyArray[0])) accuracyArray = [];
  568. accuracyArray.push([Math.round(srsUpNum / itemsList.length * 100) / 100, new Date().toLocaleString("en-US", {weekday: "short"})]);
  569. if(accuracyArray.length > 15) accuracyArray.shift(); // If the array is longer than 10 elements, remove the first one
  570. localStorage.setItem("WKSummaryAccuracyHistory", JSON.stringify(accuracyArray));
  571.  
  572. // Create a graph showing accuracy throughout the last 10 (or less) sessions
  573. let graphTitle2, graphDiv2;
  574. if(accuracyArray.length > 3) {
  575. graphTitle2 = createSummarySectionTitle(" Accuracy History", "chart-line", "var(--color-menu, #777)");
  576. // Graph
  577. graphDiv2 = document.createElement("div");
  578. graphDiv2.classList = "accuracy-graph";
  579. let graph = document.createElement("canvas");
  580. graph.style.width = "100%";
  581. graph.style.height = "100%";
  582. graphDiv2.appendChild(graph);
  583. }
  584.  
  585. // Create time tracking graphs
  586. let timeGraphTitle, timeGraphContainer;
  587. if (meaningAnswerTimes.length > 1 || readingAnswerTimes.length > 1) {
  588. timeGraphTitle = createSummarySectionTitle("Answer Time Analysis", "chart-line", "var(--color-menu, #777)");
  589.  
  590. timeGraphContainer = document.createElement("div");
  591. timeGraphContainer.className = "accuracy-graph";
  592. timeGraphContainer.style.display = "flex";
  593. timeGraphContainer.style.flexWrap = "wrap";
  594. timeGraphContainer.style.gap = "20px";
  595. timeGraphContainer.style.height = "auto";
  596. timeGraphContainer.style.padding = "20px";
  597. timeGraphContainer.style.position = "relative";
  598.  
  599.  
  600. const graphWrapperStyle = `flex: 1 1 320px; min-width:320px; height: ${GRAPH_HEIGHT + 42}px;`;
  601. const barGraphWrapperStyle = `flex: 2 1 660px; min-width:320px; height: ${GRAPH_HEIGHT + 60}px;`;
  602.  
  603. if (meaningAnswerTimes.length > 1) {
  604. let wrapper = document.createElement('div');
  605. wrapper.style.cssText = graphWrapperStyle;
  606. let canvas = document.createElement("canvas");
  607. canvas.className = "time-graph-meaning";
  608. wrapper.appendChild(canvas);
  609. timeGraphContainer.appendChild(wrapper);
  610. }
  611.  
  612. if (readingAnswerTimes.length > 1) {
  613. let wrapper = document.createElement('div');
  614. wrapper.style.cssText = graphWrapperStyle;
  615. let canvas = document.createElement("canvas");
  616. canvas.className = "time-graph-reading";
  617. wrapper.appendChild(canvas);
  618. timeGraphContainer.appendChild(wrapper);
  619. }
  620.  
  621. if (itemsList.some(item => item.totalTime > 0)) {
  622. let wrapper = document.createElement('div');
  623. wrapper.style.cssText = barGraphWrapperStyle;
  624. wrapper.style.flexBasis = '100%'; // Make bar chart take full width
  625. let canvas = document.createElement("canvas");
  626. canvas.className = "time-graph-item-bar";
  627. wrapper.appendChild(canvas);
  628. timeGraphContainer.appendChild(wrapper);
  629. }
  630. }
  631.  
  632.  
  633. // Create a div element to wrap everything
  634. let content = document.createElement("div");
  635. content.append(heading, table, incorrectTitle, listIncorrect, correctTitle, listCorrect, graphTitle ? graphTitle : "", graphDiv ? graphDiv : "", graphTitle2 ? graphTitle2 : "", graphDiv2 ? graphDiv2 : "", timeGraphTitle ? timeGraphTitle : "", timeGraphContainer ? timeGraphContainer : "");
  636. // Create a popup element with all the summary content
  637. let popup = createPopup(content);
  638.  
  639. // Create a single tooltip element for the bar chart
  640. let barChartTooltip = document.createElement('div');
  641. barChartTooltip.className = 'summary-tooltip';
  642. popup.appendChild(barChartTooltip);
  643.  
  644. (document.getElementById('turbo-body') ?? document.body).appendChild(popup);
  645.  
  646. document.body.addEventListener("keydown", keyListener);
  647.  
  648. // If it exists, fill the graph with the correctHistory array
  649. if(graphDiv) {
  650. let graph = graphDiv.querySelector("canvas");
  651. // Calculate graph data
  652. let graphData = [];
  653. for (let i = 1; i < correctHistory.length - 1; i++) {
  654. graphData.push((correctHistory[i-1] + correctHistory[i] + correctHistory[i+1]) / 3);
  655. }
  656. createGraph(graphData, graph, "🎉 Perfect session! 🎉");
  657. }
  658. // If it exists, fill the second graph with the accuracyArray array
  659. if(graphDiv2) {
  660. let graph = graphDiv2.querySelector("canvas");
  661. createGraph(accuracyArray.map(tuple => tuple[0]), graph, "🎉 Perfect history! 🎉", accuracyArray.map(tuple => tuple[1]));
  662. }
  663.  
  664. // Draw the time graphs
  665. if (timeGraphContainer) {
  666. const meaningCanvas = timeGraphContainer.querySelector('.time-graph-meaning');
  667. if (meaningCanvas) createTimeGraph(meaningAnswerTimes, meaningCanvas, "Meaning Answer Time (s)");
  668.  
  669. const readingCanvas = timeGraphContainer.querySelector('.time-graph-reading');
  670. if (readingCanvas) createTimeGraph(readingAnswerTimes, readingCanvas, "Reading Answer Time (s)");
  671.  
  672. const itemBarCanvas = timeGraphContainer.querySelector('.time-graph-item-bar');
  673. if (itemBarCanvas) createTimeBarChart(itemsList, itemBarCanvas, "Total Time per Item (s)", barChartTooltip);
  674. }
  675.  
  676. // Reset the statistics variables
  677. questionsAnswered = 0;
  678. itemsCorrect = 0;
  679. itemsIncorrect = 0;
  680. meaningCorrect = 0;
  681. meaningIncorrect = 0;
  682. readingCorrect = 0;
  683. readingIncorrect = 0;
  684. itemsList = [];
  685. // Reset time tracking variables
  686. meaningAnswerTimes = [];
  687. readingAnswerTimes = [];
  688. itemTimeMap.clear();
  689. startTime = 0;
  690.  
  691. } else {
  692. if (window.Turbo) window.Turbo.visit("https://www.wanikani.com/dashboard");
  693. else window.location.href = "https://www.wanikani.com/dashboard";
  694. }
  695. }
  696.  
  697. function addEventListeners() {
  698. if (eventListenersAdded) return;
  699. eventListenersAdded = true;
  700.  
  701. const isDoubleCheckEnabled = window.doublecheck != null;
  702. // Add an event listener for the didAnswerQuestion event
  703. window.addEventListener(isDoubleCheckEnabled ? "didFinalAnswer" : "didAnswerQuestion", function(e) {
  704. if(questionsAnswered == 0) {
  705. injectEndCode();
  706. }
  707.  
  708. // Check if the answer was correct or not by looking for the correct attribute
  709. let correct = e.detail.results.action == "pass";
  710. console.log(correct ? "Correct answer!" : "Incorrect answer!");
  711. correctHistory.push(correct);
  712.  
  713. // Record the answer entered if incorrect
  714. if (!correct) {
  715. if(!incorrectEntered.has(e.detail.subjectWithStats.subject.id)) incorrectEntered.set(e.detail.subjectWithStats.subject.id, [[], []]);
  716. if(currentQuestionType === "meaning") incorrectEntered.get(e.detail.subjectWithStats.subject.id)[0].push(e.detail.answer);
  717. else if(currentQuestionType === "reading") incorrectEntered.get(e.detail.subjectWithStats.subject.id)[1].push(e.detail.answer);
  718. }
  719.  
  720. // Increment the questions answered and correct/incorrect counters
  721. questionsAnswered++;
  722. if (currentQuestionType === "meaning") {
  723. correct ? meaningCorrect++ : meaningIncorrect++;
  724. } else if (currentQuestionType === "reading") {
  725. correct ? readingCorrect++ : readingIncorrect++;
  726. }
  727. if (correct) itemsCorrect++;
  728. else itemsIncorrect++;
  729. });
  730.  
  731. // Add an event listener for the didAnswerQuestion event
  732. window.addEventListener("didAnswerQuestion", function(e) {
  733. // Calculate and store answer time
  734. if (((isDoubleCheckEnabled && e.constructor.name === "CustomEvent") || (!isDoubleCheckEnabled && e.constructor.name === "DidAnswerQuestionEvent")) && startTime > 0) {
  735. const timeTaken = (performance.now() - startTime) / 1000; // Time in seconds
  736. if (currentQuestionType === "meaning") {
  737. meaningAnswerTimes.push(timeTaken);
  738. } else if (currentQuestionType === "reading") {
  739. readingAnswerTimes.push(timeTaken);
  740. }
  741. // Add time to the current item's total time
  742. const currentTime = itemTimeMap.get(currentSubjectId) || 0;
  743. itemTimeMap.set(currentSubjectId, currentTime + timeTaken);
  744. }
  745. });
  746.  
  747. // Add an event listener for the didCompleteSubject event
  748. window.addEventListener("didCompleteSubject", function(e) {
  749. // Get the subject data from the event detail
  750. let subject = e.detail.subjectWithStats.subject;
  751. let didSRSUp = e.detail.subjectWithStats.stats.meaning.incorrect === 0 && e.detail.subjectWithStats.stats.reading.incorrect === 0;
  752. let reading = null;
  753. if(subject.type == "Vocabulary" || subject.type == "KanaVocabulary") {
  754. reading = subject.readings?.filter(m => ["primary", "alternative"].includes(m.kind)).map(m => m.text);
  755. } else if (subject.type == "Kanji") {
  756. reading = [
  757. subject.readings?.filter(r => r.type === "nanori").map(r => r.text),
  758. subject.readings?.filter(r => r.type === "onyomi").map(r => r.text),
  759. subject.readings?.filter(r => r.type === "kunyomi").map(r => r.text),
  760. ].map(r => r?.length > 0 ? r : null);
  761. }
  762.  
  763. let isWarning = e.detail.subjectWithStats.stats.meaning.incorrect + e.detail.subjectWithStats.stats.reading.incorrect > 2 || (e.detail.subjectWithStats.stats.meaning.incorrect > 0 && e.detail.subjectWithStats.stats.reading.incorrect > 0);
  764.  
  765. // Calculate the new SRS level
  766. let newSRSLevel = didSRSUp ? currentSRSLevel + 1 : (currentSRSLevel < 2 ? currentSRSLevel : (currentSRSLevel < 5 ? currentSRSLevel - 1 : currentSRSLevel - 2));
  767.  
  768. // Get total time for this item from the map
  769. const totalTime = itemTimeMap.get(subject.id) || 0;
  770.  
  771. // Push the subject data to the items list array
  772. let subjectInfoToSave = { characters: subject.characters, type: subject.type, id: subject.id, SRSUp: didSRSUp, meanings: subject.meanings?.filter(m => ["primary", "alternative"].includes(m.kind)).map(m => m.text), readings: reading, oldSRS: currentSRSLevel, newSRS: newSRSLevel, isWarning: isWarning, incorrectEntered: incorrectEntered.get(subject.id), totalTime: totalTime };
  773. itemsList.push(subjectInfoToSave);
  774.  
  775. }, {passive: true});
  776.  
  777. // Add an event listener for the didUnanswerQuestion event
  778. window.addEventListener("didUnanswerQuestion", function(e) {
  779. // Reset the start time for the next question
  780. startTime = performance.now();
  781. }, {passive: true});
  782.  
  783. // Add an event listener for the willShowNextQuestion event
  784. window.addEventListener("willShowNextQuestion", function(e) {
  785. // Record start time for the question
  786. startTime = performance.now();
  787.  
  788. currentSRSLevel = quizQueueSRS.find(function(element) { return element[0] == e.detail.subject.id; });
  789. if(currentSRSLevel == undefined) {
  790. getQuizQueueSRS();
  791. currentSRSLevel = quizQueueSRS.find(function(element) { return element[0] == e.detail.subject.id; });
  792. if(currentSRSLevel == undefined) currentSRSLevel = [e.detail.subject.id, 10];
  793. }
  794. currentSRSLevel = currentSRSLevel[1];
  795. if(currentSRSLevel == null) {
  796. getQuizQueueSRS();
  797. currentSRSLevel = quizQueueSRS.find(function(element) { return element[0] == e.detail.subject.id; })[1];
  798. if(currentSRSLevel == null) currentSRSLevel = 10;
  799. }
  800. }, {passive: true});
  801.  
  802. // Add an event listener for the turbo before-visit event
  803. window.addEventListener("turbo:before-visit", function(e) {
  804. // Show stats if .summary-popup is not already visible and there are items reviewed
  805. if (questionsAnswered > 0 && document.getElementById("summary-popup") == null) {
  806. e.preventDefault();
  807. setTimeout(showStatistics, 0);
  808. }
  809. });
  810. }
  811.  
  812. // Home button override
  813. async function getHomeButton() {
  814. let homeButton = document.querySelector(".summary-button");
  815. if(!homeButton) setTimeout(getHomeButton, 500);
  816. else {
  817. homeButton.setAttribute("title", "Show statistics and return to dashboard");
  818. homeButton.addEventListener("click", function(e) {
  819. // Show stats if .summary-popup is not already visible and there are items reviewed
  820. if (questionsAnswered > 0 && document.getElementById("summary-popup") == null) {
  821. e.preventDefault();
  822. setTimeout(showStatistics, 0);
  823. }
  824. });
  825. }
  826. }
  827.  
  828. addEventListeners();
  829. getHomeButton();
  830. }
  831.  
  832. // Add an event listener for the willShowNextQuestion event, catching the first one as well
  833. window.addEventListener("willShowNextQuestion", function(e) {
  834. // Set current question variables with event info
  835. currentQuestionType = e.detail.questionType;
  836. currentSubjectId = e.detail.subject.id;
  837. currentCategory = e.detail.subject.type;
  838. currentWord = e.detail.subject.characters;
  839. }, {passive: true});
  840. })();