0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptで間隔反復の出題スケジューラを作る(ライトナー方式・テスト付き)

0
Posted at

この記事でやること

暗記アプリでよくある「正解するほど出題の間隔が空き、間違えた問題は翌日にまた出る」仕組みを、依存ライブラリなしの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つです。

  1. 正解したら、INTERVALS[今の段] 日後を次の出題日にして、段を1つ上げる
  2. 間違えたら、どの段にいても Lv0に戻して翌日 に出す
  3. 出題日がまだ来ていない問題に正解しても、段は上げない(正解数だけ数える)
  4. 出題日が空のカード(作ったばかり)は、すぐに出す

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にそのまま保存できます

実装

srs.js
// 間隔反復スケジューラ(段階式=ライトナー方式)
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 で仕様どおりに動くか確かめます。

srs.test.js
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

0
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?