// 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 ``; } 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 = ` New ${c.newLeft} Learning ${c.learning} To review ${c.due}`; } // ---------- 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 = `
✓ ${next.empty ? "This list is empty" : "All caught up"} ${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."}
`; 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 = `
${isNew ? 'new' : ""} ${listenButton(active.infinitif)} ${listTitle(active.listId)} · ${active.frontLabel} ${active.infinitif} space / click to show answer
`; 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 = `
${listenButton(v.infinitif)} ${v.infinitif} · English ${v.anglais} ${conjTable(v)}
${v.exemple_fr} ${v.exemple_en}
`; 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 `j'${form}`; } return `${pron} ${form}`; } function conjTable(v) { if (!v.present) return ""; const cells = v.present .map((f, i) => `${withPronoun(PRONOUNS[i], f)}`) .join(""); return `
Présent
${cells}
`; } 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 ``; }).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 = `
${listenButton(v.infinitif)} ${v.infinitif} · English ${v.anglais} ${conjTable(v)}
${v.exemple_fr} ${v.exemple_en}
Previous grade undone. Choose a new grade.
`; 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 = [``]; 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(``); } 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 = `
${title}

Today has ${c.due} review card${c.due === 1 ? "" : "s"} due across all lists.

`; } 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 = `
Couldn't load verbs.db.

Open this app through a local server (file:// blocks the database fetch):

python3 -m http.server 8000

${err.message}

`; } } 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();