summaryrefslogtreecommitdiff
path: root/script.js
diff options
context:
space:
mode:
authorJakob L. Kreuze <zerodaysfordays@sdf.org>2025-07-19 17:11:19 -0400
committerJakob L. Kreuze <zerodaysfordays@sdf.org>2025-07-19 17:11:19 -0400
commitd79ef14711c0c9c03c2373b823ecd55b4f7f5345 (patch)
treeaba0fb4b12e52b57ad1da852ecad3469f4514a2b /script.js
parent9af0c928eac7971c3e4e3a3485ee2d4f20a2f790 (diff)
Scheduling
Diffstat (limited to 'script.js')
-rw-r--r--script.js235
1 files changed, 219 insertions, 16 deletions
diff --git a/script.js b/script.js
index d6f3415..d2004ad 100644
--- a/script.js
+++ b/script.js
@@ -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')
}