diff options
| author | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2025-07-19 17:11:19 -0400 |
|---|---|---|
| committer | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2025-07-19 17:11:19 -0400 |
| commit | d79ef14711c0c9c03c2373b823ecd55b4f7f5345 (patch) | |
| tree | aba0fb4b12e52b57ad1da852ecad3469f4514a2b | |
| parent | 9af0c928eac7971c3e4e3a3485ee2d4f20a2f790 (diff) | |
Scheduling
| -rw-r--r-- | flashcards.html | 5 | ||||
| -rw-r--r-- | script.js | 235 | ||||
| -rw-r--r-- | script.js.bak | 108 | ||||
| -rw-r--r-- | style.css | 2 |
4 files changed, 223 insertions, 127 deletions
diff --git a/flashcards.html b/flashcards.html index 50f3f4d..4727889 100644 --- a/flashcards.html +++ b/flashcards.html @@ -22,11 +22,12 @@ <div id="button-container"> <button id="rating-button-forgot" class="rating-button">Forgot [1]</button> <button id="rating-button-hard" class="rating-button">Hard [2]</button> - <button id="rating-button-medium" class="rating-button">Medium [3]</button> + <button id="rating-button-good" class="rating-button">Good [3]</button> <button id="rating-button-easy" class="rating-button">Easy [4]</button> </div> - <div id="toast"></div> + <div id="toast"> + </div> <script src="script.js"></script> </body> @@ -1,3 +1,122 @@ +function reportException(e) { + const error = e instanceof Error ? e : new Error(e.toString()); + console.log(JSON.stringify(error.stack)); + showToast(`An error has occurred!\n\n${error}\n\n${error.stack}`); +} + +/*********************************************************************** + * Actual FSRS5 algorithm * + ***********************************************************************/ + +const W = [ + 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 0.1192, 1.01925, + 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621, +]; + +const F = 19.0 / 81.0; +const C = -0.5; + +const Grade = { + forgot: 1.0, + hard: 2.0, + good: 3.0, + easy: 4.0 +}; + +function retrievability(t, s) { + return Math.pow(1.0 + F * (t / s), C); +} + +function interval(r_d, s) { + return (s / F) * (Math.pow(r_d, 1.0 / C) - 1.0); +} + +function s_0(g) { + switch (g) { + case Grade.forgot: return W[0]; + case Grade.hard: return W[1]; + case Grade.good: return W[2]; + case Grade.easy: return W[3]; + } +} + +function s_success(d, s, r, g) { + const t_d = 11.0 - d; + const t_s = Math.pow(s, -W[9]); + const t_r = Math.exp(W[10] * (1.0 - r)) - 1.0; + const h = g === Grade.hard ? W[15] : 1.0; + const b = g === Grade.easy ? W[16] : 1.0; + const c = Math.exp(W[8]); + const alpha = 1.0 + t_d * t_s * t_r * h * b * c; + return s * alpha; +} + +function s_fail(d, s, r) { + const d_f = Math.pow(d, -W[12]); + const s_f = Math.pow(s + 1.0, W[13]) - 1.0; + const r_f = Math.exp(W[14] * (1.0 - r)); + const c_f = W[11]; + const result = d_f * s_f * r_f * c_f; + return Math.min(result, s); +} + +function stability(d, s, r, g) { + return g === Grade.forgot ? s_fail(d, s, r) : s_success(d, s, r, g); +} + +function clamp_d(d) { + return Math.min(10.0, Math.max(1.0, d)); +} + +function d_0(g) { + const g_value = Grade[g]; + return clamp_d(W[4] - Math.exp(W[5] * (g_value - 1.0)) + 1.0); +} + +function difficulty(d, g) { + return clamp_d(W[7] * d_0('easy') + (1.0 - W[7]) * dp(d, g)); +} + +function dp(d, g) { + return d + delta_d(g) * ((10.0 - d) / 9.0); +} + +function delta_d(g) { + const g_value = Grade[g]; + return -W[6] * (g_value - 3.0); +} + +function sim(grades) { + let t = 0.0; + const r_d = 0.9; + const steps = []; + + // Initial review + if (grades.length === 0) { + throw new Error("Grades cannot be empty"); + } + + let gradesCopy = [...grades]; + let g = gradesCopy.shift(); // Remove the first grade + let s = s_0(g); + let d = d_0(g); + let i = Math.max(Math.round(interval(r_d, s)), 1.0); + + steps.push({ t, s, d, i }); + + // n-th review + for (let g of gradesCopy) { + t += i; + const r = retrievability(i, s); + s = stability(d, s, r, g); + d = difficulty(d, g); + i = Math.max(Math.round(interval(r_d, s)), 1.0); + steps.push({ t, s, d, i }); + } + + return steps; +} + /*********************************************************************** * State file processing * ***********************************************************************/ @@ -22,6 +141,10 @@ function loadCard(card) { } function flipCard() { + if (window.index >= window.cards.length) { + return; + } + window.card.revealed = true; // Reveal the "back". @@ -37,26 +160,105 @@ function flipCard() { window.buttons.style.display = "flex"; } +function endReview() { + clearInterval(window.updateInterval); + window.statusInfo.innerHTML = "Session complete!"; + window.flashcard.style.display = "none"; + window.buttons.style.display = "none"; + + // TODO: Update cards. +} + function rateCard(difficulty) { if (!window.card.revealed) { return; } - // if (difficulty === "forgot") { - // } - if (++window.index < window.cards.length) { + const card = window.cards[window.index]; + + const r_d = 0.9; + const s = card.s; + const d = card.d; + const i = card.i; + const g = card.flagged ? Grade.forgot : Grade[difficulty]; + + let s_n = undefined, d_n = undefined, i_n = undefined; + + if (s == null || d == null || i == null) { + s_n = s_0(g); + d_n = d_0(g); + } else { + const r = retrievability(i, s); + s_n = stability(d, s, r, g); + d_n = difficulty(d, g); + } + i_n = Math.max(Math.round(interval(r_d, s)), 1.0); + + const scheduled = new Date(card.scheduled); + scheduled.setDate(scheduled + i); + + if (difficulty === "forgot") { + // Shift forgotten card to end. + window.cards.push(card); + window.cards.splice(window.index, 1); loadCard(window.cards[window.index]); } else { - clearInterval(window.updateInterval); - window.statusInfo.innerHTML = "Session complete!"; - window.flashcard.style.display = "none"; - window.buttons.style.display = "none"; + // Unflag and update scheduler parameters. + delete card.flagged; + card.s = s_n; + card.d = d_n; + card.i = i_n; + + if (++window.index < window.cards.length) { + loadCard(window.cards[window.index]); + } else { + endReview(); + } + } +} + +// Update `window.allCards` with new scheduler parameters. +function updateAllCards() { + for (let i = 0; i < window.allCards.length; i++) { + const updated = window.cards.find((card) => card.id == window.allCards[i].id); + if (updated) { + window.allCards[i] = updated; + } } } -// Todo: check if card is revealed before allowing button inpt. +function today() { + let today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +} + +function checkCards(cards) { + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + if (!card.id) { + throw new Error("malformed card: missing id") + } else if (!card.front) { + throw new Error(`malformed card ${card.id}: missing front`) + } else if (!card.back) { + throw new Error(`malformed card ${card.id}: missing bacak`) + } + if (!card.scheduled) { + card.scheduled = today().toJSON(); + } + if (!card.scheduler) { + card.scheduler = { + s: null, + d: null, + i: null + } + } + } +} function loadCards(data) { + checkCards(data); + document.getElementById('flashcard').style.display = "block"; window.allCards = data; @@ -73,8 +275,8 @@ function loadCards(data) { .addEventListener('click', () => rateCard('forgot')); document.getElementById('rating-button-hard') .addEventListener('click', () => rateCard('hard')); - document.getElementById('rating-button-medium') - .addEventListener('click', () => rateCard('medium')); + document.getElementById('rating-button-good') + .addEventListener('click', () => rateCard('good')); document.getElementById('rating-button-easy') .addEventListener('click', () => rateCard('easy')); @@ -85,7 +287,6 @@ function loadCards(data) { return (new Date(card.scheduled)) <= today; }); - // Temporary loadCard(window.cards[0]); window.updateInterval = setInterval(() => { @@ -99,7 +300,7 @@ function loadCards(data) { function showToast(message, duration = 3000) { const toast = document.getElementById("toast"); - toast.textContent = message; + toast.innerHTML = "<pre>" + message + "</pre>"; // Show the toast toast.classList.add("show"); @@ -111,6 +312,7 @@ function showToast(message, duration = 3000) { } window.addEventListener("load", (event) => { + document.getElementById('cards-input').addEventListener('change', function(event) { const file = event.target.files[0]; if (!file) return; @@ -120,18 +322,19 @@ window.addEventListener("load", (event) => { try { const content = e.target.result; loadCards(JSON.parse(content)); - } catch (error) { - console.error("JSON Parsing Error:", error); + } catch (e) { + reportException(e); } }; reader.onerror = function(error) { document.getElementById('result').textContent = "Error reading file: " + error; - console.error("File Reading Error:", error); + reportException(error); }; reader.readAsText(file); // Read the file as text }); + }); window.addEventListener('keydown', (event) => { @@ -143,7 +346,7 @@ window.addEventListener('keydown', (event) => { } else if (event.code === 'Digit2' || event.code === "Numpad2") { rateCard('easy') } else if (event.code === 'Digit3' || event.code === "Numpad3") { - rateCard('medium') + rateCard('good') } else if (event.code === 'Digit4' || event.code === "Numpad4") { rateCard('hard') } diff --git a/script.js.bak b/script.js.bak index c81f2e5..e9b9378 100644 --- a/script.js.bak +++ b/script.js.bak @@ -90,111 +90,3 @@ function main() { // All functions } -const W = [ - 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 0.1192, 1.01925, - 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621, -]; - -const F = 19.0 / 81.0; -const C = -0.5; - -const Grade = { - Forgot: 1.0, - Hard: 2.0, - Good: 3.0, - Easy: 4.0 -}; - -function retrievability(t, s) { - return Math.pow(1.0 + F * (t / s), C); -} - -function interval(r_d, s) { - return (s / F) * (Math.pow(r_d, 1.0 / C) - 1.0); -} - -function s_0(g) { - switch (g) { - case Grade.Forgot: return W[0]; - case Grade.Hard: return W[1]; - case Grade.Good: return W[2]; - case Grade.Easy: return W[3]; - } -} - -function s_success(d, s, r, g) { - const t_d = 11.0 - d; - const t_s = Math.pow(s, -W[9]); - const t_r = Math.exp(W[10] * (1.0 - r)) - 1.0; - const h = g === Grade.Hard ? W[15] : 1.0; - const b = g === Grade.Easy ? W[16] : 1.0; - const c = Math.exp(W[8]); - const alpha = 1.0 + t_d * t_s * t_r * h * b * c; - return s * alpha; -} - -function s_fail(d, s, r) { - const d_f = Math.pow(d, -W[12]); - const s_f = Math.pow(s + 1.0, W[13]) - 1.0; - const r_f = Math.exp(W[14] * (1.0 - r)); - const c_f = W[11]; - const result = d_f * s_f * r_f * c_f; - return Math.min(result, s); -} - -function stability(d, s, r, g) { - return g === Grade.Forgot ? s_fail(d, s, r) : s_success(d, s, r, g); -} - -function clamp_d(d) { - return Math.min(10.0, Math.max(1.0, d)); -} - -function d_0(g) { - const g_value = Grade[g]; - return clamp_d(W[4] - Math.exp(W[5] * (g_value - 1.0)) + 1.0); -} - -function difficulty(d, g) { - return clamp_d(W[7] * d_0('Easy') + (1.0 - W[7]) * dp(d, g)); -} - -function dp(d, g) { - return d + delta_d(g) * ((10.0 - d) / 9.0); -} - -function delta_d(g) { - const g_value = Grade[g]; - return -W[6] * (g_value - 3.0); -} - -function sim(grades) { - let t = 0.0; - const r_d = 0.9; - const steps = []; - - // Initial review - if (grades.length === 0) { - throw new Error("Grades cannot be empty"); - } - - let gradesCopy = [...grades]; - let g = gradesCopy.shift(); // Remove the first grade - let s = s_0(g); - let d = d_0(g); - let i = Math.max(Math.round(interval(r_d, s)), 1.0); - - steps.push({ t, s, d, i }); - - // n-th review - for (let g of gradesCopy) { - t += i; - const r = retrievability(i, s); - s = stability(d, s, r, g); - d = difficulty(d, g); - i = Math.max(Math.round(interval(r_d, s)), 1.0); - steps.push({ t, s, d, i }); - } - - return steps; -} @@ -54,7 +54,7 @@ body { #toast { position: fixed; top: 20px; - right: 20px; + left: 20px; background-color: #333; color: white; padding: 12px 24px; |