この記事でやること
暗記アプリでよくある「正解するほど出題の間隔が空き、間違えた問題は翌日にまた出る」仕組みを、依存ライブラリなしのJavaScriptで実装します。
- 方式は 段階式(ライトナー方式)。正解で1段上がり、不正解で最初の段へ戻る
- 次の出題までの間隔は
[1, 3, 7, 14, 30, 90]日 - 本体は約40行。
node:testで動作確認まで行います
動作環境は Node.js 18 以上(node:test を使うため)です。ブラウザでもそのまま動きます。
間隔反復とは
間隔反復(Spaced Repetition)とは、覚えた内容を忘れかけたころに復習するように、復習の間隔を少しずつ広げていく学習法です。
実装方式は主に2つあります。
| 方式 | 次の出題日の決め方 | 向いている入力 |
|---|---|---|
| 段階式(ライトナー方式) | カードごとの「段」で決まる | 正解/不正解の2値(4択・入力式など) |
| SM-2系(SuperMemo・Anki) | 易しさ係数と前回の間隔から計算する | 学習者の自己評価(0〜5など) |
4択問題のように、ツールが受け取れるのが「正解か不正解か」だけなら、段階式のほうが素直に実装できます。
仕様
| 今の段 | 正解したときの次の出題 | 正解後の段 |
|---|---|---|
| Lv0(新規・間違えた問題) | 翌日 | Lv1 |
| Lv1 | 3日後 | Lv2 |
| Lv2 | 7日後 | Lv3 |
| Lv3 | 14日後 | Lv4 |
| Lv4 | 30日後 | Lv5 |
| Lv5 | 90日後 | Lv5のまま |
ルールは4つです。
- 正解したら、
INTERVALS[今の段]日後を次の出題日にして、段を1つ上げる - 間違えたら、どの段にいても Lv0に戻して翌日 に出す
- 出題日がまだ来ていない問題に正解しても、段は上げない(正解数だけ数える)
- 出題日が空のカード(作ったばかり)は、すぐに出す
3つ目は、「全問を解く」モードがあるときに必要になります。これがないと、直前に見たばかりの問題を何回か解くだけで、1日で最上段まで上がってしまいます。
カードのデータ
カード1枚を次の形で持ちます。
const card = {
q: '1+1は?', // 問題
level: 0, // 段(0〜5)
next: '', // 次の出題日(YYYY-MM-DD)。空なら新規
ok: 0, // 正解数
ng: 0, // 不正解数
};
next を Date ではなく YYYY-MM-DD 形式の文字列 にしているのがポイントです。
- タイムスタンプで持つと、昨日の23時に解いた問題の「翌日」が今日の23時になり、朝に開いても出てきません。文字列の日付なら、日付が変わった時点で出題対象になります
- ゼロ埋めした
YYYY-MM-DDは、文字列の大小比較がそのまま日付の前後比較 になります - CSVやJSONにそのまま保存できます
実装
// 間隔反復スケジューラ(段階式=ライトナー方式)
export const MAX_LEVEL = 5;
export const INTERVALS = [1, 3, 7, 14, 30, 90];
// ローカル日付を YYYY-MM-DD で返す
export function formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function addDays(ymd, days) {
const [y, m, d] = ymd.split('-').map(Number);
return formatDate(new Date(y, m - 1, d + days));
}
// 出題日が空(新規)か、今日以前なら出題する
export function isDue(card, todayYmd) {
return !card.next || card.next <= todayYmd;
}
// 回答を反映した新しいカードを返す(元のカードは変更しない)
export function applyAnswer(card, isCorrect, todayYmd) {
if (!isCorrect) {
return { ...card, level: 0, next: addDays(todayYmd, 1), ng: card.ng + 1 };
}
if (!isDue(card, todayYmd)) {
return { ...card, ok: card.ok + 1 };
}
const days = INTERVALS[Math.min(card.level, MAX_LEVEL)];
return {
...card,
level: Math.min(card.level + 1, MAX_LEVEL),
next: addDays(todayYmd, days),
ok: card.ok + 1,
};
}
export function dueCards(cards, todayYmd) {
return cards.filter(card => isDue(card, todayYmd));
}
日付の足し算
addDays は、new Date(年, 月, 日 + 日数) で日付を作り直しています。Date のコンストラクタは、範囲外の日(10月32日など)を翌月へ繰り上げてくれるので、月末・年末・うるう年を自分で処理する必要がありません。
formatDate で getFullYear() などのローカル時刻のメソッド を使っているのも大事なところです。toISOString().slice(0, 10) で日付を作ると、UTCで計算されるため、日本時間の0時〜9時には前日の日付になってしまいます。
applyAnswer の分岐
applyAnswer は、回答の結果によって3通りに分かれます。
| 条件 | 段 | 次の出題日 | カウント |
|---|---|---|---|
| 不正解 | 0に戻す | 翌日 |
ng +1 |
| 正解・出題日前 | そのまま | そのまま |
ok +1 |
| 正解・出題日以降 | +1(上限5) |
INTERVALS[段] 日後 |
ok +1 |
不正解だけは、出題日前でもLv0に戻します。予定より前に解いて間違えたなら、その時点で忘れているので、待つ理由がありません。
どの分岐でも スプレッド構文で新しいオブジェクトを返し、元のカードは書き換えません。Reactなどの状態管理に渡すときも、そのまま使えます。
テストで動きを確認する
node:test で仕様どおりに動くか確かめます。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyAnswer, addDays, dueCards } from './srs.js';
const newCard = { q: '1+1は?', level: 0, next: '', ok: 0, ng: 0 };
test('正解を続けると間隔が 1→3→7→14→30→90 日と広がる', () => {
let card = newCard;
let day = '2026-10-06';
const gaps = [];
for (let i = 0; i < 7; i++) {
card = applyAnswer(card, true, day);
const [a, b] = [new Date(day), new Date(card.next)];
gaps.push(Math.round((b - a) / 86400000));
day = card.next;
}
assert.deepEqual(gaps, [1, 3, 7, 14, 30, 90, 90]);
assert.equal(card.level, 5);
});
test('間違えたらどの段からでも Lv0・翌日', () => {
const card = { ...newCard, level: 4, next: '2026-10-06' };
const after = applyAnswer(card, false, '2026-10-06');
assert.equal(after.level, 0);
assert.equal(after.next, '2026-10-07');
});
test('出題日前に正解しても段は上がらない', () => {
const card = { ...newCard, level: 2, next: '2026-10-10' };
const after = applyAnswer(card, true, '2026-10-06');
assert.equal(after.level, 2);
assert.equal(after.next, '2026-10-10');
assert.equal(after.ok, 1);
});
test('出題日前でも間違えたら Lv0 に戻る', () => {
const card = { ...newCard, level: 3, next: '2026-10-20' };
assert.equal(applyAnswer(card, false, '2026-10-06').level, 0);
});
test('月末・年末をまたいでも日付が繰り上がる', () => {
assert.equal(addDays('2026-10-31', 1), '2026-11-01');
assert.equal(addDays('2026-12-20', 14), '2027-01-03');
assert.equal(addDays('2028-02-28', 1), '2028-02-29');
});
test('今日の復習は出題日が今日以前と新規だけ', () => {
const cards = [
{ ...newCard, q: 'a', next: '' },
{ ...newCard, q: 'b', next: '2026-10-05' },
{ ...newCard, q: 'c', next: '2026-10-06' },
{ ...newCard, q: 'd', next: '2026-10-07' },
];
assert.deepEqual(dueCards(cards, '2026-10-06').map(c => c.q), ['a', 'b', 'c']);
});
test('元のカードは変更しない', () => {
const card = { ...newCard };
applyAnswer(card, true, '2026-10-06');
assert.deepEqual(card, newCard);
});
package.json に "type": "module" を書いてから実行します。
node --test
ℹ tests 7
ℹ pass 7
ℹ fail 0
1つ目のテストで、正解を続けたときに間隔が 1 → 3 → 7 → 14 → 30 → 90 → 90 日と広がることを確認しています。
使い方の例
アプリ側では、「今日の復習」を開くときに dueCards で対象を絞り、回答のたびに applyAnswer でカードを置き換えます。
import { applyAnswer, dueCards, formatDate } from './srs.js';
const today = formatDate(new Date());
let cards = loadCards(); // 保存先から読み込む(実装は任意)
const todays = dueCards(cards, today);
function answer(cardIndex, isCorrect) {
cards = cards.map((card, i) => (i === cardIndex ? applyAnswer(card, isCorrect, today) : card));
saveCards(cards); // 保存先へ書き戻す(実装は任意)
}
loadCards / saveCards の中身は用途に合わせて実装してください。localStorage、IndexedDB、ファイル保存のどれでも、カードが文字列と数値だけでできているので、そのままシリアライズできます。
カスタマイズ
間隔を変えたいときは INTERVALS の配列を書き換えるだけです。
// 短期集中(試験直前の1か月など)
export const INTERVALS = [1, 2, 4, 7, 14, 30];
MAX_LEVEL は INTERVALS.length - 1 に合わせてください。配列より段が多いと、INTERVALS[段] が undefined になり、次の出題日が壊れます。
まとめ
- 正解/不正解の2値しか取れないなら、間隔反復は段階式(ライトナー方式)で十分に実装できる
- 間隔は配列1本で持ち、
INTERVALS[今の段]日後を次の出題日にする - 出題日はローカル日付の
YYYY-MM-DD文字列で持つ。toISOString()はUTCなので使わない - 出題日前の正解では段を上げない。不正解はいつでもLv0・翌日
- 元のカードを書き換えず、新しいオブジェクトを返す
このスケジューラを組み込んだ4択の暗記ツールを 自分だけの問題集 として公開しています(登録不要・ブラウザ完結)。問題と学習記録はCSV/JSONファイルでPCに保存する作りです。
SEOスコアチェックツール: Direbase(ディレベース) — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work