/* Core Recall — local-first flashcards, FSRS-5 scheduler. No build step, no deps. */
'use strict';
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
import { DAY, clamp, fmtInterval, parseCSV } from './core.js';
import { db, setDB, blank, load, save, newCard, deckOf, cardsOf, todayLog, counts, buildQueue, plan } from './store.js';
/* DAY, clamp, schedule, fmtInterval, parseCSV come from core.js */
const uid =() => Math.random().toString(36).slice(2, 10);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const dayKey = (t, resetHour = db?.settings?.resetHour ?? 4) => {
const d = new Date(t);
if (d.getHours() < resetHour) d.setDate(d.getDate() - 1);
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
};
const startOfDay = (t, resetHour = db?.settings?.resetHour ?? 4) => {
const d = new Date(t);
if (d.getHours() < resetHour) d.setDate(d.getDate() - 1);
d.setHours(resetHour, 0, 0, 0);
return d.getTime();
};
const prevResetDay = (t, daysAgo) => {
const d = new Date(t);
d.setDate(d.getDate() - daysAgo);
return startOfDay(d.getTime(), db?.settings?.resetHour ?? 4);
};
let view = 'home', openDeck = null;
/* ---------------- ui helpers ---------------- */
let toastTimer;
function toast(msg) {
const t = $('#toast');
t.textContent = msg; t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.hidden = true, 2600);
}
const icon = (n, cls = 'ic') => ``;
let sheetSave = null;
function openSheet(title, bodyHTML, onSave, okLabel = '保存') {
$('#sheet-title').textContent = title;
$('#sheet-body').innerHTML = bodyHTML;
$('#sheet-ok').textContent = okLabel;
$('#sheet-ok').hidden = !onSave;
sheetSave = onSave;
$('#scrim').hidden = $('#sheet').hidden = false;
setTimeout(() => $('#sheet-body input, #sheet-body textarea')?.focus(), 350);
}
function closeSheet() { $('#scrim').hidden = $('#sheet').hidden = true; sheetSave = null; }
function speak(text) {
if (!window.speechSynthesis) return toast('この端末は読み上げに対応していません');
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = /[-ヿ一-龯]/.test(text) ? 'ja-JP' : 'en-US';
speechSynthesis.speak(u);
}
/* ---------------- home ---------------- */
function renderHome() {
const banner = $('#backup-banner');
const stale = db.cards.length > 0 && Date.now() - db.lastBackup > 7 * DAY;
banner.innerHTML = stale
? `
${icon('warn')}
データのバックアップ(JSON出力)を行ってください
`
: '';
const list = $('#deck-list');
if (!db.decks.length) {
list.innerHTML = `
デッキがありません右上の+から最初のデッキを作りましょう。
`;
} else {
list.innerHTML = db.decks.map(d => {
const c = counts(d.id), total = cardsOf(d.id).length;
const clear = c.n + c.l + c.d === 0;
return ``;
}).join('');
}
// 12 weeks of activity, columns = weeks, rows = weekday
const per = {};
db.log.forEach(l => { const k = dayKey(l.t); per[k] = (per[k] || 0) + 1; });
const max = Math.max(1, ...Object.values(per));
const today = startOfDay(Date.now());
const cells = [];
const back = 83 + ((new Date(today).getDay() + 6) % 7); // pad so the last column ends this week
for (let i = back; i >= 0; i--) {
const t = prevResetDay(today, i), n = per[dayKey(t)] || 0;
const lvl = n === 0 ? 0 : Math.min(4, Math.ceil(n / max * 4));
cells.push(``);
}
$('#heatmap').innerHTML = cells.join('');
}
/* ---------------- stats ---------------- */
function renderStats() {
const t = todayLog();
$('#s-today').textContent = t.length;
$('#s-total').textContent = db.log.length.toLocaleString();
const days = new Set(db.log.map(l => dayKey(l.t)));
let streak = 0, cur = startOfDay(Date.now());
if (!days.has(dayKey(cur))) cur = prevResetDay(cur, 1);
while (days.has(dayKey(cur))) { streak++; cur = prevResetDay(cur, 1); }
$('#s-streak').textContent = streak;
const wd = ['日', '月', '火', '水', '木', '金', '土'];
const last7 = [];
for (let i = 6; i >= 0; i--) {
const d = prevResetDay(startOfDay(Date.now()), i);
last7.push({ label: wd[new Date(d).getDay()], n: db.log.filter(l => dayKey(l.t) === dayKey(d)).length });
}
$('#bars').innerHTML = barsHTML(last7);
const g = [1, 2, 3, 4].map(n => db.log.filter(l => l.g === n).length);
const sum = g.reduce((a, b) => a + b, 0) || 1;
const names = ['もう一度', '難しい', '普通', '簡単'];
$('#retention').innerHTML = g.map((n, i) =>
`
${names[i]}${Math.round(n / sum * 100)}%
`).join('');
$('#stackbar').innerHTML = g.map((n, i) => ``).join('');
const fc = [];
for (let i = 0; i < 7; i++) {
const from = prevResetDay(startOfDay(Date.now()), -i), to = prevResetDay(from, -1);
fc.push({ label: i === 0 ? '今日' : wd[new Date(from).getDay()], n: db.cards.filter(c => c.reps && c.due < to && c.due >= (i ? from : 0)).length });
}
$('#forecast').innerHTML = barsHTML(fc);
}
function barsHTML(rows) {
const max = Math.max(1, ...rows.map(r => r.n));
return rows.map(r => `
${r.n || ''}${r.label}
`).join('');
}
/* ---------------- settings ---------------- */
function renderSettings() {
$('#set-reset').value = db.settings.resetHour ?? 4;
$('#set-new').value = db.settings.new;
$('#set-max').value = db.settings.max;
$('#set-ret').value = db.settings.ret;
$('#set-theme').value = db.settings.theme;
}
function applyTheme() {
// An imported backup (or an older one, naming the retired "system") can carry a
// theme this build doesn't have — don't leave --surface undefined. Compare the
// option values directly: the saved name is untrusted, and interpolating it into
// a selector throws on a quote, after save() has already persisted it.
const known = [...$('#set-theme').options].some(o => o.value === db.settings.theme);
if (!known) { db.settings.theme = 'light'; save(); }
document.documentElement.className = `theme-${db.settings.theme}`;
// keep the iOS status bar / PWA chrome in step with the surface colour
$('#meta-theme').content = getComputedStyle(document.body).backgroundColor;
}
/* ---------------- deck detail ---------------- */
function renderDeck() {
const d = deckOf(openDeck);
if (!d) return closeDeck();
const c = counts(d.id), cards = cardsOf(d.id);
$('#dv-title').textContent = d.name;
$('#dv-counts').innerHTML =
`
${c.n}新規
${c.l}学習中
${c.d}復習
`;
$('#opt-reverse').checked = !!d.reverse;
$('#opt-shuffle').checked = !!d.shuffle;
$('#btn-study').disabled = buildQueue(d.id).length === 0;
$('#btn-study').textContent = $('#btn-study').disabled
? (cards.length ? '今日の分は終了しました' : 'カードがありません') : '学習を開始';
const label = $('#dv-cards-label');
label.hidden = !cards.length;
label.textContent = `カード(${cards.length}枚)`;
$('#dv-cards').innerHTML = cards.map(c => ``).join('');
}
function openDeckView(id) { openDeck = id; $('#deck-view').hidden = false; renderDeck(); }
function closeDeck() { $('#deck-view').hidden = true; openDeck = null; render(); }
/* ---------------- review ---------------- */
let queue = [], current = null, answered = 0, flipped = false, sessionStart = 0, reviewDeck = null;
/** Which side of the card is the question — the deck's 表裏入れ替え option decides. */
const faces = card => deckOf(reviewDeck)?.reverse
? { q: card.back, a: card.front }
: { q: card.front, a: card.back };
function paintFaces() {
const { q, a } = faces(current);
$('#rv-front').textContent = q;
$('#rv-front2').textContent = q;
$('#rv-back').textContent = a;
}
function startReview(deckId) {
reviewDeck = deckId;
queue = buildQueue(deckId);
if (!queue.length) return toast('今日の学習は完了しています');
answered = 0; sessionStart = Date.now();
$('#rv-deck').textContent = deckOf(deckId).name;
$('#rv-done').hidden = true;
$('#review').hidden = false;
nextCard();
}
function endReview() {
$('#review').hidden = true;
speechSynthesis?.cancel();
if (openDeck) renderDeck();
render();
}
function nextCard() {
current = queue[0];
if (!current) return finishReview();
flipped = false;
// Snap back to the front with no animation: rotating back would show the *next*
// card's answer for the length of the flip. The new card fades in instead.
const f = $('#rv-flip');
f.classList.add('no-anim');
f.classList.remove('flipped', 'enter');
paintFaces();
void f.offsetWidth; // commit the un-flip and rewind the enter animation
f.classList.remove('no-anim');
f.classList.add('enter');
$('#rating').classList.add('hidden');
const total = answered + queue.length;
$('#rv-count').textContent = `${answered + 1} / ${total}`;
$('#rv-bar').style.width = (answered / total * 100) + '%';
const now = Date.now();
[1, 2, 3, 4].forEach(g => $('#iv' + g).textContent = fmtInterval(plan(current, g, now).days));
}
function flip() {
if (!current || flipped) return;
flipped = true;
$('#rv-flip').classList.remove('enter'); // don't let the entrance fight the flip
$('#rv-flip').classList.add('flipped');
$('#rating').classList.remove('hidden');
}
function grade(g) {
if (!current || !flipped) return;
const now = Date.now();
const r = plan(current, g, now);
const wasNew = !current.reps;
Object.assign(current, { s: r.s, d: r.d, due: r.due, state: r.state, last: now, reps: current.reps + 1 });
if (g === 1) current.lapses++;
db.log.push({ t: now, g, n: wasNew ? 1 : 0, c: current.id });
save();
queue.shift();
if (g === 1) queue.splice(Math.min(queue.length, 3), 0, current); // show again later this session
else answered++;
nextCard();
}
function finishReview() {
const mins = Math.max(1, Math.round((Date.now() - sessionStart) / 60000));
$('#rv-done-sub').textContent = `${answered}枚を復習しました(約${mins}分)`;
const days = new Set(db.log.map(l => dayKey(l.t)));
let streak = 0, cur = startOfDay(Date.now());
if (!days.has(dayKey(cur))) cur = prevResetDay(cur, 1);
while (days.has(dayKey(cur))) { streak++; cur = prevResetDay(cur, 1); }
if (streak > 0) {
$('#rv-streak-count').textContent = streak;
$('#rv-done-streak').hidden = false;
} else {
$('#rv-done-streak').hidden = true;
}
$('#rv-done').hidden = false;
$('#rating').classList.add('hidden');
}
/* ---------------- card / deck editing ---------------- */
function cardSheet(card) {
const isNew = !card;
openSheet(isNew ? 'カードを追加' : 'カードを編集', `
${isNew ? '
保存後もシートは開いたままなので、続けて追加できます。
'
: ``}`, () => {
const front = $('#f-front').value.trim(), back = $('#f-back').value.trim();
if (!front || !back) return toast('表と裏の両方を入力してください');
if (isNew) {
db.cards.push(newCard(openDeck, front, back));
save(); renderDeck();
$('#f-front').value = $('#f-back').value = ''; $('#f-front').focus();
toast('追加しました');
return false; // keep the sheet open for rapid entry
}
card.front = front; card.back = back; save();
if (current && current.id === card.id) paintFaces();
renderDeck();
});
$('#f-del')?.addEventListener('click', () => {
if (!confirm('このカードを削除しますか?')) return;
db.cards = db.cards.filter(c => c.id !== card.id);
queue = queue.filter(c => c.id !== card.id);
save(); closeSheet(); renderDeck();
if (current?.id === card.id) nextCard();
});
}
function deckSheet() {
openSheet('新しいデッキ', ``, () => {
const name = $('#f-name').value.trim();
if (!name) return toast('名前を入力してください');
const d = { id: uid(), name, created: Date.now() };
db.decks.push(d); save(); render();
openDeckView(d.id);
}, '作成');
}
/* ---------------- import / export ---------------- */
function exportJSON() {
db.lastBackup = Date.now(); save();
const blob = new Blob([JSON.stringify(db, null, 1)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `core-recall-${dayKey(Date.now())}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
render();
toast('バックアップを出力しました');
}
let pendingImport = null;
function pickFile(kind) { pendingImport = kind; $('#file-input').value = ''; $('#file-input').click(); }
async function handleFile(file) {
const text = await file.text();
if (pendingImport === 'json') {
let data;
try { data = JSON.parse(text); } catch { return toast('JSON を読み込めませんでした'); }
if (!Array.isArray(data.decks) || !Array.isArray(data.cards)) return toast('Core Recall のバックアップではありません');
if (!confirm(`現在のデータを置き換えます。\nデッキ ${data.decks.length}件 / カード ${data.cards.length}枚を復元しますか?`)) return;
setDB(Object.assign(blank(), data)); save(); applyTheme(); render(); renderSettings();
return toast('復元しました');
}
const rows = parseCSV(text);
if (!rows.length) return toast('カードが見つかりませんでした');
const name = file.name.replace(/\.[^.]+$/, '') || 'インポート';
const deck = { id: uid(), name, created: Date.now() };
db.decks.push(deck);
rows.forEach(r => db.cards.push(newCard(deck.id, r[0].trim(), r[1].trim())));
save(); render();
toast(`「${name}」に ${rows.length}枚を取り込みました`);
}
function wipe() {
if (!confirm('すべてのデッキ・カード・学習履歴を消去します。元に戻せません。続けますか?')) return;
if (!confirm('本当によろしいですか? 先に JSON バックアップを取ることを強くおすすめします。')) return;
setDB(blank()); save(); render(); renderSettings();
toast('すべてのデータを消去しました');
}
/* ---------------- routing ---------------- */
function render() {
$$('.screen[data-tab]').forEach(s => s.hidden = s.dataset.tab !== view);
$$('#tabbar button').forEach(b => b.classList.toggle('active', b.dataset.goto === view));
({ home: renderHome, stats: renderStats, settings: renderSettings })[view]();
}
function go(tab) {
$('#deck-view').hidden = true; openDeck = null; // tab bar always wins over a pushed screen
view = tab; render();
}
/* ---------------- events ---------------- */
document.addEventListener('click', e => {
const t = e.target;
if (t.classList.contains('a') && t.closest('.card-row')) {
e.stopPropagation();
e.preventDefault();
t.classList.toggle('revealed');
return;
}
const tab = t.closest('[data-goto]'); if (tab) return go(tab.dataset.goto);
const deck = t.closest('[data-deck]'); if (deck) return openDeckView(deck.dataset.deck);
const card = t.closest('[data-card]'); if (card) return cardSheet(db.cards.find(c => c.id === card.dataset.card));
if (t.closest('[data-close="deck-view"]')) return closeDeck();
if (t.closest('[data-close-review]')) return endReview();
if (t.closest('[data-sheet-cancel]') || t.id === 'scrim') return closeSheet();
if (t.closest('[data-edit]')) return cardSheet(current);
const sp = t.closest('[data-speak]'); if (sp) return speak(faces(current)[sp.dataset.speak === 'front' ? 'q' : 'a']);
const g = t.closest('[data-g]'); if (g) return grade(+g.dataset.g);
if (t.closest('#banner-export') || t.closest('#btn-export')) return exportJSON();
});
$('#sheet-ok').onclick = () => { if (sheetSave && sheetSave() !== false) closeSheet(); };
$('#btn-add-deck').onclick = deckSheet;
$('#btn-add-card').onclick = () => cardSheet(null);
$('#btn-study').onclick = () => startReview(openDeck);
$('#btn-end').onclick = endReview;
$('#btn-del-deck').onclick = () => {
const d = deckOf(openDeck);
if (!confirm(`「${d.name}」と ${cardsOf(d.id).length}枚のカードを削除しますか?`)) return;
db.cards = db.cards.filter(c => c.deck !== d.id);
db.decks = db.decks.filter(x => x.id !== d.id);
save(); closeDeck();
};
$('#rv-flip').onclick = e => { if (!e.target.closest('button')) flip(); }; // tools stay clickable
$('#set-theme').onchange = e => { db.settings.theme = e.target.value; save(); applyTheme(); };
['reverse', 'shuffle'].forEach(k => {
$('#opt-' + k).onchange = e => { deckOf(openDeck)[k] = e.target.checked; save(); renderDeck(); };
});
$('#btn-import-json').onclick = () => pickFile('json');
function showCSVImportModal() {
const bodyHTML = `