// 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 = `
${v.exemple_fr}
${v.exemple_en}
${v.exemple_fr}
${v.exemple_en}
Today has ${c.due} review card${c.due === 1 ? "" : "s"} due across all lists.
Open this app through a local server (file:// blocks the database fetch):
python3 -m http.server 8000
${err.message}