566 lines
19 KiB
JavaScript
566 lines
19 KiB
JavaScript
// Anki-style spaced repetition (SM-2) over flashcard lists.
|
|
// List metadata and schedules live in localStorage; the -er verb cards come from verbs.db.
|
|
|
|
const SRS_KEY = "frenchJourney.srs";
|
|
const LISTS_KEY = "frenchJourney.flashcardLists";
|
|
const FORCE_KEY = "frenchJourney.forceCard";
|
|
const DEFAULT_LIST_ID = "er-verbs";
|
|
const TODAY_ID = "today";
|
|
|
|
// --- Scheduler constants (Anki defaults) ---
|
|
const MIN = 60000, DAY = 86400000;
|
|
const LEARN_STEPS = [1, 10]; // minutes (new cards)
|
|
const RELEARN_STEPS = [10]; // minutes (lapsed cards)
|
|
const HARD_LEARN_DELAY = 6; // minutes; keeps Hard distinct from Again on new cards
|
|
const GRAD_INT = 1; // days, graduating interval (Good)
|
|
const EASY_INT = 4; // days, graduating interval (Easy)
|
|
const START_EASE = 2.5;
|
|
const EASY_BONUS = 1.3;
|
|
const HARD_FACTOR = 1.2;
|
|
const MIN_EASE = 1.3;
|
|
const NEW_PER_DAY = 20;
|
|
|
|
const cardEl = document.getElementById("card");
|
|
const countsEl = document.getElementById("counts");
|
|
const showRow = document.getElementById("show-row");
|
|
const gradeRow = document.getElementById("grade-row");
|
|
const listGridEl = document.getElementById("list-grid");
|
|
const todayPanelEl = document.getElementById("today-panel");
|
|
const undoEl = document.getElementById("undo");
|
|
|
|
let listCards = {};
|
|
let lists = [];
|
|
let selectedListId = DEFAULT_LIST_ID;
|
|
let store = { cards: {}, meta: { date: "", newCount: 0 } };
|
|
let active = null; // current card being shown
|
|
let revealed = false;
|
|
let undo = null;
|
|
let forcedCardKey = null;
|
|
|
|
// ---------- persistence ----------
|
|
function load() {
|
|
try {
|
|
const s = JSON.parse(localStorage.getItem(SRS_KEY));
|
|
if (s && s.cards) store = s;
|
|
} catch {}
|
|
migrateLegacySchedule();
|
|
loadLists();
|
|
forcedCardKey = localStorage.getItem(FORCE_KEY);
|
|
rolloverDay();
|
|
}
|
|
function save() { localStorage.setItem(SRS_KEY, JSON.stringify(store)); }
|
|
function saveLists() { localStorage.setItem(LISTS_KEY, JSON.stringify(lists)); }
|
|
function today() { return new Date().toISOString().slice(0, 10); }
|
|
function rolloverDay() {
|
|
if (store.meta.date !== today()) store.meta = Object.assign({}, store.meta, { date: today(), newCount: 0 });
|
|
}
|
|
function loadLists() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(LISTS_KEY));
|
|
if (Array.isArray(saved)) lists = saved.filter((l) => l && l.id && l.title);
|
|
} catch {}
|
|
if (!lists.some((l) => l.id === DEFAULT_LIST_ID)) {
|
|
lists.unshift({ id: DEFAULT_LIST_ID, title: "-er verbs", source: "verbs-db", createdAt: Date.now() });
|
|
}
|
|
if (!lists.length) lists = [{ id: DEFAULT_LIST_ID, title: "-er verbs", source: "verbs-db", createdAt: Date.now() }];
|
|
saveLists();
|
|
}
|
|
function migrateLegacySchedule() {
|
|
if (store.meta && store.meta.listMigration === 1) return;
|
|
const migrated = {};
|
|
for (const [key, value] of Object.entries(store.cards || {})) {
|
|
migrated[key.includes(":") ? key : `${DEFAULT_LIST_ID}:${key}`] = value;
|
|
}
|
|
store.cards = migrated;
|
|
store.meta = Object.assign({}, store.meta, { listMigration: 1 });
|
|
save();
|
|
}
|
|
|
|
// ---------- scheduler ----------
|
|
function fresh() {
|
|
return { state: "new", ease: START_EASE, interval: 0, step: 0, due: 0, reps: 0, lapses: 0, lapsed: 1 };
|
|
}
|
|
|
|
// Returns a NEW schedule object for `prev` given a grade, without mutating.
|
|
function schedule(prev, grade, now) {
|
|
const c = Object.assign(fresh(), prev);
|
|
if (c.state === "new") { c.state = "learning"; c.step = 0; }
|
|
|
|
const learning = c.state === "learning" || c.state === "relearning";
|
|
|
|
if (learning) {
|
|
const steps = c.state === "relearning" ? RELEARN_STEPS : LEARN_STEPS;
|
|
if (grade === "again") {
|
|
c.step = 0;
|
|
c.due = now;
|
|
} else if (grade === "hard") {
|
|
const nextStep = steps[Math.min(c.step + 1, steps.length - 1)];
|
|
c.due = now + Math.max(HARD_LEARN_DELAY, nextStep / 2) * MIN;
|
|
} else if (grade === "good") {
|
|
c.step += 1;
|
|
if (c.step >= steps.length) {
|
|
c.state = "review";
|
|
c.interval = c.lapsed && prev && prev.state === "relearning" ? Math.max(1, c.lapsed) : GRAD_INT;
|
|
c.due = now + c.interval * DAY;
|
|
c.reps += 1;
|
|
} else {
|
|
c.due = now + steps[c.step] * MIN;
|
|
}
|
|
} else { // easy -> graduate immediately
|
|
c.state = "review";
|
|
c.interval = EASY_INT;
|
|
c.due = now + c.interval * DAY;
|
|
c.reps += 1;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
// review state
|
|
if (grade === "again") {
|
|
c.lapses += 1;
|
|
c.ease = Math.max(MIN_EASE, c.ease - 0.2);
|
|
c.lapsed = Math.max(1, Math.round(c.interval * 0)); // 0% new interval -> floor 1d on relearn
|
|
c.state = "relearning";
|
|
c.step = 0;
|
|
c.due = now + RELEARN_STEPS[0] * MIN;
|
|
} else if (grade === "hard") {
|
|
c.ease = Math.max(MIN_EASE, c.ease - 0.15);
|
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * HARD_FACTOR));
|
|
c.due = now + c.interval * DAY;
|
|
c.reps += 1;
|
|
} else if (grade === "good") {
|
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * c.ease));
|
|
c.due = now + c.interval * DAY;
|
|
c.reps += 1;
|
|
} else { // easy
|
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * c.ease * EASY_BONUS));
|
|
c.ease += 0.15;
|
|
c.due = now + c.interval * DAY;
|
|
c.reps += 1;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
function fmtUntil(ms) {
|
|
if (ms < 0) ms = 0;
|
|
if (ms === 0) return "now";
|
|
if (ms < DAY) {
|
|
const mins = Math.round(ms / MIN);
|
|
if (mins < 1) return "<1m";
|
|
if (mins < 60) return mins + "m";
|
|
return Math.round(mins / 60) + "h";
|
|
}
|
|
const days = ms / DAY;
|
|
if (days < 30) return Math.round(days) + "d";
|
|
if (days < 365) return (days / 30).toFixed(1).replace(/\.0$/, "") + "mo";
|
|
return (days / 365).toFixed(1).replace(/\.0$/, "") + "y";
|
|
}
|
|
|
|
// ---------- audio ----------
|
|
function speakFrench(text) {
|
|
if (!("speechSynthesis" in window) || !text) return;
|
|
window.speechSynthesis.cancel();
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.lang = "fr-FR";
|
|
utterance.rate = 0.9;
|
|
window.speechSynthesis.speak(utterance);
|
|
}
|
|
|
|
function listenButton(text) {
|
|
return `<button class="fc-listen" type="button" data-speak="${escapeHtml(text)}" aria-label="Listen">
|
|
<span class="fc-listen-icon" aria-hidden="true">🔊</span>
|
|
</button>`;
|
|
}
|
|
|
|
function wireListenButtons() {
|
|
cardEl.querySelectorAll("[data-speak]").forEach((button) => {
|
|
button.onclick = (e) => {
|
|
e.stopPropagation();
|
|
speakFrench(button.dataset.speak);
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------- queue ----------
|
|
function activeList() { return lists.find((l) => l.id === selectedListId); }
|
|
function cardsForList(id) { return listCards[id] || []; }
|
|
function studyCards() {
|
|
if (selectedListId === TODAY_ID) return lists.flatMap((l) => cardsForList(l.id));
|
|
return cardsForList(selectedListId);
|
|
}
|
|
function cardKey(v) { return `${v.listId}:${v.id}`; }
|
|
function schedOf(v) { return store.cards[cardKey(v)]; }
|
|
function listTitle(id) {
|
|
const l = lists.find((x) => x.id === id);
|
|
return l ? l.title : "Unknown list";
|
|
}
|
|
|
|
function pickNext() {
|
|
const now = Date.now();
|
|
const cards = studyCards();
|
|
if (!cards.length) return { done: true, empty: true, wait: null };
|
|
const forced = forcedCardKey && cards.find((card) => cardKey(card) === forcedCardKey);
|
|
if (forced) return { card: forced, forced: true };
|
|
const learnDue = [], reviewDue = [], news = [];
|
|
let learnAhead = Infinity;
|
|
|
|
for (const v of cards) {
|
|
const s = schedOf(v);
|
|
if (!s) { news.push(v); continue; }
|
|
const isLearn = s.state === "learning" || s.state === "relearning";
|
|
if (s.due <= now) {
|
|
if (isLearn) learnDue.push(v); else reviewDue.push(v);
|
|
} else if (isLearn) {
|
|
learnAhead = Math.min(learnAhead, s.due - now);
|
|
}
|
|
}
|
|
learnDue.sort((a, b) => schedOf(a).due - schedOf(b).due);
|
|
reviewDue.sort((a, b) => schedOf(a).due - schedOf(b).due);
|
|
|
|
if (learnDue.length) return { card: learnDue[0] };
|
|
if (reviewDue.length) return { card: reviewDue[0] };
|
|
if (selectedListId === TODAY_ID) return { done: true, wait: learnAhead === Infinity ? null : learnAhead };
|
|
if (news.length) return { card: news[0] };
|
|
return { done: true, wait: learnAhead === Infinity ? null : learnAhead };
|
|
}
|
|
|
|
function counts(cards = studyCards(), includeNew = selectedListId !== TODAY_ID) {
|
|
const now = Date.now();
|
|
let learning = 0, due = 0, newSeen = 0;
|
|
for (const v of cards) {
|
|
const s = schedOf(v);
|
|
if (!s) { newSeen++; continue; }
|
|
if (s.state === "learning" || s.state === "relearning") learning++;
|
|
else if (s.due <= now) due++;
|
|
}
|
|
const newLeft = includeNew ? newSeen : 0;
|
|
return { newLeft, learning, due };
|
|
}
|
|
|
|
function renderCounts() {
|
|
const c = counts();
|
|
countsEl.innerHTML = `
|
|
<span class="cnt new">New <b>${c.newLeft}</b></span>
|
|
<span class="cnt learn">Learning <b>${c.learning}</b></span>
|
|
<span class="cnt rev">To review <b>${c.due}</b></span>`;
|
|
}
|
|
|
|
// ---------- rendering ----------
|
|
function render({ speak = true } = {}) {
|
|
renderLists();
|
|
renderToday();
|
|
renderCounts();
|
|
renderUndo();
|
|
const next = pickNext();
|
|
|
|
if (next.done) {
|
|
active = null;
|
|
showRow.hidden = true;
|
|
gradeRow.hidden = true;
|
|
cardEl.className = "fc-card done";
|
|
cardEl.innerHTML = `
|
|
<div class="fc-face">
|
|
<span class="fc-done-check">✓</span>
|
|
<span class="fc-term" style="font-size:24px">${next.empty ? "This list is empty" : "All caught up"}</span>
|
|
<span class="fc-hint">${next.empty
|
|
? "Add cards later by asking me to fill this list."
|
|
: next.wait != null
|
|
? "Next card due in " + fmtUntil(next.wait)
|
|
: selectedListId === TODAY_ID
|
|
? "No more reviews due today."
|
|
: "No more cards for today. Come back tomorrow."}</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
active = next.card;
|
|
if (next.forced) {
|
|
forcedCardKey = null;
|
|
localStorage.removeItem(FORCE_KEY);
|
|
}
|
|
revealed = false;
|
|
const isNew = !schedOf(active);
|
|
cardEl.className = "fc-card";
|
|
cardEl.innerHTML = `
|
|
<div class="fc-face fc-front">
|
|
${isNew ? '<span class="fc-tag new-tag">new</span>' : ""}
|
|
${listenButton(active.infinitif)}
|
|
<span class="fc-label">${listTitle(active.listId)} · ${active.frontLabel}</span>
|
|
<span class="fc-term">${active.infinitif}</span>
|
|
<span class="fc-hint">space / click to show answer</span>
|
|
</div>`;
|
|
wireListenButtons();
|
|
if (speak) speakFrench(active.infinitif);
|
|
showRow.hidden = false;
|
|
gradeRow.hidden = true;
|
|
}
|
|
|
|
function renderUndo() {
|
|
undoEl.disabled = !undo;
|
|
}
|
|
|
|
function findCardByKey(key) {
|
|
for (const cards of Object.values(listCards)) {
|
|
const found = cards.find((card) => cardKey(card) === key);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function reveal() {
|
|
if (!active || revealed) return;
|
|
revealed = true;
|
|
const v = active;
|
|
cardEl.innerHTML = `
|
|
<div class="fc-face fc-back">
|
|
${listenButton(v.infinitif)}
|
|
<span class="fc-label">${v.infinitif} · English</span>
|
|
<span class="fc-term en">${v.anglais}</span>
|
|
${conjTable(v)}
|
|
<div class="fc-example">
|
|
<code>${v.exemple_fr}</code>
|
|
<span class="fc-ex-en">${v.exemple_en}</span>
|
|
</div>
|
|
</div>`;
|
|
wireListenButtons();
|
|
showRow.hidden = true;
|
|
renderGrades();
|
|
gradeRow.hidden = false;
|
|
}
|
|
|
|
// present-tense conjugation table (with elision: je -> j' before a vowel/h)
|
|
const PRONOUNS = ["je", "tu", "il/elle", "nous", "vous", "ils/elles"];
|
|
function withPronoun(pron, form) {
|
|
if (pron === "je" && /^[aeiouyhàâäéèêëîïôöûü]/i.test(form)) {
|
|
return `<span class="cj-p">j'</span>${form}`;
|
|
}
|
|
return `<span class="cj-p">${pron} </span>${form}`;
|
|
}
|
|
function conjTable(v) {
|
|
if (!v.present) return "";
|
|
const cells = v.present
|
|
.map((f, i) => `<span class="cj-cell">${withPronoun(PRONOUNS[i], f)}</span>`)
|
|
.join("");
|
|
return `<div class="fc-conj">
|
|
<div class="fc-conj-label">Présent</div>
|
|
<div class="fc-conj-grid">${cells}</div>
|
|
</div>`;
|
|
}
|
|
|
|
const GRADES = [
|
|
{ key: "again", label: "Again", cls: "again" },
|
|
{ key: "hard", label: "Hard", cls: "hard" },
|
|
{ key: "good", label: "Good", cls: "good" },
|
|
{ key: "easy", label: "Easy", cls: "easy" },
|
|
];
|
|
|
|
function renderGrades() {
|
|
const now = Date.now();
|
|
const prev = schedOf(active);
|
|
gradeRow.innerHTML = GRADES.map((g) => {
|
|
const next = schedule(prev, g.key, now);
|
|
return `<button class="fc-btn grade ${g.cls}" data-grade="${g.key}">
|
|
<span class="g-label">${g.label}</span>
|
|
<span class="g-int">${fmtUntil(next.due - now)}</span>
|
|
</button>`;
|
|
}).join("");
|
|
gradeRow.querySelectorAll("button").forEach((b) => {
|
|
b.onclick = () => grade(b.dataset.grade);
|
|
});
|
|
}
|
|
|
|
function grade(g) {
|
|
if (!active) return;
|
|
const key = cardKey(active);
|
|
const previousSchedule = schedOf(active);
|
|
const wasNew = !schedOf(active);
|
|
undo = {
|
|
key,
|
|
previousSchedule: previousSchedule ? Object.assign({}, previousSchedule) : null,
|
|
wasNew,
|
|
meta: Object.assign({}, store.meta),
|
|
};
|
|
store.cards[key] = schedule(previousSchedule, g, Date.now());
|
|
if (wasNew) { rolloverDay(); store.meta.newCount += 1; }
|
|
save();
|
|
render();
|
|
}
|
|
|
|
function undoGrade() {
|
|
if (!undo) return;
|
|
const card = findCardByKey(undo.key);
|
|
if (!card) {
|
|
undo = null;
|
|
renderUndo();
|
|
return;
|
|
}
|
|
if (undo.previousSchedule) store.cards[undo.key] = undo.previousSchedule;
|
|
else delete store.cards[undo.key];
|
|
store.meta = Object.assign({}, undo.meta);
|
|
active = card;
|
|
revealed = true;
|
|
undo = null;
|
|
save();
|
|
renderLists();
|
|
renderToday();
|
|
renderCounts();
|
|
renderUndo();
|
|
revealRestored(card);
|
|
}
|
|
|
|
function revealRestored(card) {
|
|
cardEl.className = "fc-card";
|
|
const v = card;
|
|
cardEl.innerHTML = `
|
|
<div class="fc-face fc-back">
|
|
${listenButton(v.infinitif)}
|
|
<span class="fc-label">${v.infinitif} · English</span>
|
|
<span class="fc-term en">${v.anglais}</span>
|
|
${conjTable(v)}
|
|
<div class="fc-example">
|
|
<code>${v.exemple_fr}</code>
|
|
<span class="fc-ex-en">${v.exemple_en}</span>
|
|
</div>
|
|
<span class="fc-hint">Previous grade undone. Choose a new grade.</span>
|
|
</div>`;
|
|
wireListenButtons();
|
|
showRow.hidden = true;
|
|
renderGrades();
|
|
gradeRow.hidden = false;
|
|
}
|
|
|
|
function renderLists() {
|
|
const todayActive = selectedListId === TODAY_ID ? " active" : "";
|
|
const todayCount = counts(lists.flatMap((l) => cardsForList(l.id)), false).due;
|
|
const items = [`<button class="fc-list-item${todayActive}" data-list-id="${TODAY_ID}">
|
|
<span class="dot ${todayCount ? "on" : ""}"></span>
|
|
<span class="fc-list-title">Today</span>
|
|
<span class="fc-list-count">${todayCount}</span>
|
|
</button>`];
|
|
for (const list of lists) {
|
|
const c = counts(cardsForList(list.id), true);
|
|
const total = cardsForList(list.id).length;
|
|
const activeCls = selectedListId === list.id ? " active" : "";
|
|
items.push(`<button class="fc-list-item${activeCls}" data-list-id="${list.id}">
|
|
<span class="dot ${c.due ? "on" : ""}"></span>
|
|
<span class="fc-list-title">${escapeHtml(list.title)}</span>
|
|
<span class="fc-list-count">${total}</span>
|
|
</button>`);
|
|
}
|
|
listGridEl.innerHTML = items.join("");
|
|
listGridEl.querySelectorAll("[data-list-id]").forEach((b) => {
|
|
b.onclick = () => {
|
|
selectedListId = b.dataset.listId;
|
|
render();
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderToday() {
|
|
const c = counts(lists.flatMap((l) => cardsForList(l.id)), false);
|
|
const title = selectedListId === TODAY_ID
|
|
? "Studying today's due cards"
|
|
: `Studying ${escapeHtml(activeList()?.title || "list")}`;
|
|
todayPanelEl.innerHTML = `<div>
|
|
<span class="fc-section-label">${title}</span>
|
|
<p>Today has <b>${c.due}</b> review card${c.due === 1 ? "" : "s"} due across all lists.</p>
|
|
</div>`;
|
|
}
|
|
|
|
function addList() {
|
|
const title = prompt("List title");
|
|
if (!title || !title.trim()) return;
|
|
const id = slugify(title.trim());
|
|
let unique = id || `list-${Date.now()}`;
|
|
let n = 2;
|
|
while (lists.some((l) => l.id === unique) || unique === TODAY_ID) unique = `${id}-${n++}`;
|
|
lists.push({ id: unique, title: title.trim(), source: "manual", createdAt: Date.now() });
|
|
listCards[unique] = [];
|
|
selectedListId = unique;
|
|
saveLists();
|
|
render();
|
|
}
|
|
|
|
function slugify(s) {
|
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (ch) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[ch]));
|
|
}
|
|
|
|
// ---------- boot ----------
|
|
async function init() {
|
|
load();
|
|
try {
|
|
const SQL = await initSqlJs({
|
|
locateFile: (f) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${f}`,
|
|
});
|
|
const buf = await fetch("verbs.db").then((r) => {
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
return r.arrayBuffer();
|
|
});
|
|
const db = new SQL.Database(new Uint8Array(buf));
|
|
const res = db.exec("SELECT infinitif, anglais, exemple_fr, exemple_en FROM verbs ORDER BY infinitif;");
|
|
listCards[DEFAULT_LIST_ID] = res[0].values.map(([infinitif, anglais, exemple_fr, exemple_en]) => ({
|
|
id: infinitif,
|
|
listId: DEFAULT_LIST_ID,
|
|
frontLabel: "French · infinitive",
|
|
infinitif, anglais, exemple_fr, exemple_en,
|
|
}));
|
|
|
|
// present-tense conjugations, keyed by infinitive
|
|
const conj = {};
|
|
const cres = db.exec("SELECT infinitif, p1, p2, p3, p4, p5, p6 FROM conjugations WHERE tense='présent';");
|
|
if (cres[0]) {
|
|
for (const [inf, ...forms] of cres[0].values) conj[inf] = forms;
|
|
}
|
|
listCards[DEFAULT_LIST_ID].forEach((v) => { v.present = conj[v.infinitif] || null; });
|
|
lists.forEach((l) => { if (!listCards[l.id]) listCards[l.id] = []; });
|
|
|
|
document.getElementById("add-list").onclick = addList;
|
|
document.getElementById("show").onclick = reveal;
|
|
undoEl.onclick = undoGrade;
|
|
cardEl.onclick = () => { if (!revealed) reveal(); };
|
|
document.getElementById("reset").onclick = () => {
|
|
if (!confirm("Reset all spaced-repetition progress?")) return;
|
|
store = { cards: {}, meta: { date: today(), newCount: 0, listMigration: 1 } };
|
|
undo = null;
|
|
save();
|
|
render();
|
|
};
|
|
wireKeys();
|
|
render({ speak: false });
|
|
} catch (err) {
|
|
showRow.hidden = true;
|
|
cardEl.innerHTML = `<div class="fc-error">
|
|
<strong>Couldn't load verbs.db.</strong>
|
|
<p>Open this app through a local server (file:// blocks the database fetch):</p>
|
|
<code>python3 -m http.server 8000</code>
|
|
<p class="fc-err-detail">${err.message}</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function wireKeys() {
|
|
document.addEventListener("keydown", (e) => {
|
|
if ((e.key === "Backspace" || e.key.toLowerCase() === "z") && undo) {
|
|
e.preventDefault();
|
|
undoGrade();
|
|
return;
|
|
}
|
|
if (!active) return;
|
|
if (!revealed) {
|
|
if (e.key === " " || e.key === "Enter") { e.preventDefault(); reveal(); }
|
|
return;
|
|
}
|
|
if (e.key === "1") grade("again");
|
|
else if (e.key === "2") grade("hard");
|
|
else if (e.key === "3" || e.key === " ") { e.preventDefault(); grade("good"); }
|
|
else if (e.key === "4") grade("easy");
|
|
});
|
|
}
|
|
|
|
init();
|