開発mdx-playgroundメモ

クイズの復習フィルタを「累計誤答」から「直近連続誤答」に変えた話

都道府県の位置を当てる地図クイズで、「過去3回の解答履歴を localStorage に残して、間違った県だけ復習できるようにする」機能を入れた。最初は素直に「累計誤答回数」でフィルタしていたが、すぐに学習体験として詰まることに気づいて、「直近で何連続 × か」でフィルタする形に直した。

仕分けクイズなど他の暗記系コンテンツでも同じ判定パターンを使い回したいので、ビフォー/アフター・なぜ直したのかをまとめておく。

やりたかったこと

  • 47県のうち、間違いやすい県だけ繰り返し復習したい
  • 過去の正誤履歴はローカルに残し、画面で一望できるようにしたい
  • 1問につき「正解 or その問題内で1度でも誤答したら ×」の1試行として記録
  • 履歴は直近3回ぶんで十分

直近3回の履歴を [newest, mid, oldest] の順に localStorage に保存する。

type Result = 'o' | 'x'
type HistoryMap = Record<string, Result[]> // prefId -> 直近3件、先頭が最新

const addHistoryResult = (prefId: string, result: Result) => {
  const cur = history.value[prefId] ?? []
  const next = [result, ...cur].slice(0, 3)
  history.value = { ...history.value, [prefId]: next }
  writeHistory(history.value)
}

表示は「右が新しい」順にしたいので、画面側は reverse 相当に並べ替える。

const displayMarks = (prefId: string): Array<'o' | 'x' | 'empty'> => {
  const arr = history.value[prefId] ?? []
  const padded: Array<'o' | 'x' | 'empty'> = ['empty', 'empty', 'empty']
  for (let i = 0; i < arr.length && i < 3; i++) {
    padded[2 - i] = arr[i]!
  }
  return padded
}

ここまでは特に迷うところがない。問題はフィルタの定義の方だった。

ビフォー:累計誤答回数でフィルタ

最初に作ったときは、こう書いていた。

// 過去3回の中で × が何回あったか
const countWrongInHistory = (h: HistoryMap, prefId: string): number =>
  (h[prefId] ?? []).filter((r) => r === 'x').length

const selectByFilter = (h: HistoryMap, f: RangeFilter): Prefecture[] => {
  if (f === 'all') return [...allPrefectures]
  const min = f === 'wrong1' ? 1 : f === 'wrong2' ? 2 : 3
  return allPrefectures.filter((p) => countWrongInHistory(h, p.prefId) >= min)
}

UI ボタンも「≧1誤 / ≧2誤 / 3誤」で、シンプルに「過去3回のうち何回以上間違えたか」で絞る形。

何が困ったか

≧1誤 を選ぶと、「1回間違えて、その後2回連続で正解した」県もずっと出題対象に残り続ける。3回目までは履歴が [o, o, x] のように残るので、× が 1回以上ある を満たしてしまう。

学習者の感覚としては、

  • 1回間違えた
  • 次に正解した
  • もう一度正解した

の時点で「もうこの県は覚えた」と判定したい。なのに ≧1誤 のままだと一向に対象から外れない。結果、出題リストが減らず、復習モードに入ったメリットが薄い。

「卒業条件」が暗黙にも明示にも定義されていない、というのが本質的な問題だった。

アフター:直近の連続誤答数でフィルタ

「直近から連続して × が続いている数」をカウントし、その連続数でフィルタするように直した。

// 直近から連続している × の数(一度でも ○ が入れば 0 にリセット)
// 履歴配列は [newest, mid, oldest] の順で保存している
const countRecentWrongStreak = (h: HistoryMap, prefId: string): number => {
  const arr = h[prefId] ?? []
  let streak = 0
  for (const r of arr) {
    if (r === 'x') streak++
    else break
  }
  return streak
}

const selectByFilter = (h: HistoryMap, f: RangeFilter): Prefecture[] => {
  if (f === 'all') return [...allPrefectures]
  const min = f === 'streak1' ? 1 : f === 'streak2' ? 2 : 3
  return allPrefectures.filter((p) => countRecentWrongStreak(h, p.prefId) >= min)
}

UI ボタンは「直近× / 2連× / 3連×」に変えた。

この設計の良いところ

  • 「1回正解できれば卒業」が暗黙に定義される:直近1回でも o を出したら streak がリセットされてフィルタ対象から外れる
  • 失敗が積み上がる感覚:連続して間違えるほど streak が伸び、表示の × × × がそのまま視覚的なヤバさになる
  • 「過去にやらかしたが今はできる」と「今もできない」を分けられる:累計だと両者が混じるが、直近連続だけ見れば「現状」の弱点が出る

例:

履歴(新→古)累計×直近連続×旧フィルタ ≧1誤新フィルタ 直近×
o o o00対象外対象外
o x x20対象 ❌対象外 ✅
x o x21対象 ❌対象
x x o22対象対象(2連でも対象)
x x x33対象対象

o x x が旧フィルタでは「対象」になっていたのが、新フィルタでは「対象外」になる。これがちゃんと「卒業」できる形。

逆に x o x は「最近やらかしたが、その前は1回できていた」状態で、まだ油断できない。新フィルタでは直近1連×として残る。意図通り。

どこをいじったか

apps/web/app/pages/prefecture-quiz/find-prefecture.vue の以下を差し替え:

  • RangeFilter の型: 'wrong1' | 'wrong2' | 'wrong3''streak1' | 'streak2' | 'streak3'
  • 判定関数: countWrongInHistorycountRecentWrongStreak
  • UI ラベル: '≧1誤' / '≧2誤' / '3誤''直近×' / '2連×' / '3連×'
  • localStorage キー (prefectureQuiz:find-prefecture:filter) の値は古い値が読まれたら 'all' にフォールバック

履歴の表示そのもの(右が新しい o x x 風の3マス)は変えていない。フィルタの解釈だけを差し替えた。

他のクイズに使い回すときのチェックリスト

同じ「直近連続誤答」パターンを別のクイズに移植するとき、最低限これだけ揃えれば動く。

  • 履歴を [newest, mid, oldest] の順で保存する(先頭に unshift 相当、長さ3でカット)
  • 1問につき1試行ぶんを記録する(その問題内で何回誤答しても × は1つ)
  • 「直近で連続している × の数」を数える関数を1つ用意する(先頭から x が続く長さを返すだけ)
  • フィルタは streak >= 1 / 2 / 3 の3段階で十分。>= にしておくと「2連× を選ぶと 3連× も含まれる」自然な挙動になる
  • フィルタを変えた瞬間に出題範囲を切り替えて再シャッフル(途中まで解いた状態を引きずらない)
  • フィルタ後に0件になる選択肢はボタンを非アクティブにする(または選んだ瞬間に注意を出す)
  • 「履歴リセット」ボタンを置く。リセット後はフィルタも all に戻す
  • 履歴の各マークは「右が新しい」で揃える(人間が直感的に時系列を読みやすい)
  • 表示パネルでは「過去3回に1回でも × があった県」を薄く色付け(一覧で弱点が目立つ)。これは累計誤答の判定なのでフィルタとは別の用途で残しておく
  • localStorage の値が壊れていても落ちないようバリデーション。古いフィルタ値が残っていたら all にフォールバック

仕分けクイズに移すときに気をつけたい点

仕分けクイズだと「問題=1つの仕訳」「正解=借方科目と貸方科目の組み合わせ」になる。1問の中で複数の判断(借方科目・貸方科目・金額)を行うので、「その問題で1度でも誤答したら ×」のルールをそのまま使うか、判定の粒度を上げるかの分岐がある。

  • 粗くする:仕訳1件まるごと正解で o、どこかでも違ったら x
  • 細かくする:借方/貸方/金額の各ステップで o/x を別々に持ち、ステップ単位で復習対象を絞る

最初は粗くで十分。学習者が「この仕訳パターンは何回やってもまだ間違える」を視覚化できれば、その時点で復習フィルタは仕事をしている。細かい粒度はあとから足せる。

まとめ

  • 「過去N回で何回間違えたか」で復習対象を決めると、卒業条件が定義できなくて学習が前に進まない
  • 「直近で連続何回間違えているか」に切り替えると、1回正解した時点でフィルタから外れて自然に卒業する
  • 履歴の保存形式([newest, ..., oldest] の固定長3)はそのままで、判定関数だけ差し替えれば移行できる
  • 表示の ○ × - は触らずに済む。学習者から見える情報は同じで、復習対象の解釈だけが変わる

仕分け系・暗記系のクイズコンテンツを増やすときは、最初からこのパターンで作る。