クイズの復習フィルタを「累計誤答」から「直近連続誤答」に変えた話
都道府県の位置を当てる地図クイズで、「過去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 o | 0 | 0 | 対象外 | 対象外 |
o x x | 2 | 0 | 対象 ❌ | 対象外 ✅ |
x o x | 2 | 1 | 対象 ❌ | 対象 |
x x o | 2 | 2 | 対象 | 対象(2連でも対象) |
x x x | 3 | 3 | 対象 | 対象 |
o x x が旧フィルタでは「対象」になっていたのが、新フィルタでは「対象外」になる。これがちゃんと「卒業」できる形。
逆に x o x は「最近やらかしたが、その前は1回できていた」状態で、まだ油断できない。新フィルタでは直近1連×として残る。意図通り。
どこをいじったか
apps/web/app/pages/prefecture-quiz/find-prefecture.vue の以下を差し替え:
RangeFilterの型:'wrong1' | 'wrong2' | 'wrong3'→'streak1' | 'streak2' | 'streak3'- 判定関数:
countWrongInHistory→countRecentWrongStreak - 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)はそのままで、判定関数だけ差し替えれば移行できる - 表示の
○ × -は触らずに済む。学習者から見える情報は同じで、復習対象の解釈だけが変わる
仕分け系・暗記系のクイズコンテンツを増やすときは、最初からこのパターンで作る。