学習ゲートの問題集アプリをNuxt 3に実装した — dev専用API・サーバー側採点・間隔反復に干渉しない設計
コミットのたびにクイズを解かされる学習ゲートを運用し始めて、ひとつ物足りなさがあった。解いた問題がその場で流れていって、解き直す場所がない。間違えた論点は /review の間隔反復で戻ってくるが、「今すぐまとめて解き直したい」に応える入口がなかった。そこで今日は、レッスン記事の末尾と専用ページの両方から過去問を何回でも解ける問題集アプリのPhase 2-3を、先に固めておいた計画書を正としてClaude Codeに実装させた。
計画書を先に固めてから任せた
実装セッションの前に計画書(memo/2026-07-06/lesson-quiz-app-plan.md)を書き、Codexレビューを2周回して致命的指摘4件を反映済みにしておいた。指摘はどれも「動いてから気づくと痛い」類のものだった。
attemptsテーブルのNOT NULLカラム(repo / question / correct / round / source)は、サーバーがconcepts行から復元して全て埋める- 選択肢のインデックス契約を明文化する(後述。表示順のまま送ると誤採点になる)
conceptsクエリ省略時は全論点を返す(practiceページの「全論点から出題」用)- DoDの「本番に含まれない」という表現を「本番で到達不能」に修正(ページJSがバンドルに入ること自体は既存のunpublished機構と同等に許容する)
実装セッションではこの計画書を最初に読ませて、アーキテクチャ・API仕様・タスク順序はファイルを正とする形で走らせた。曖昧な口頭指示で往復するより、レビュー済みの計画書1枚を渡すほうが手戻りが少ない。
設計の柱は4つ
データの二重管理をしないのが大前提。問題文・選択肢・正解のSSOTはTursoの concepts.payload、回答履歴は attempts のままで、Nuxt側はdev専用のNitro API 3本(questions / history / attempt)で読み書きするだけにした。
サーバー側採点。 GET /api/lessons/questions は payload から correct_index を落として返し、答え合わせは POST /api/lessons/attempt がサーバー側で行う。DevToolsのネットワークタブを開いても正解が見えないので、「解く前に答えが分かる」事故が構造的に起きない。
インデックス契約。 APIが返す選択肢は常に元順で、並び替えはクライアントの責務。シャッフル写像 displayToOriginal[表示位置] = 元インデックス を保持し、回答POSTでは元順インデックスに戻して送る。
// 表示はシャッフル、採点は元順。この写像を純粋関数に閉じ込めた
const mapping = shuffleMapping(choices.length) // displayToOriginal
post({ conceptId, chosenIndex: mapping[displayIndex] })
表示順のまま送ると、サーバーは元順の correct_index と比較するため誤採点になる。この往復を app/utils/lesson-quiz-client.ts の純粋関数に閉じ込めて、テストで両方向を検証させた。
間隔反復(SM-2)に干渉しない。 practiceの回答は attempts に source='practice' でINSERTするだけで、concepts の ease / due_at / streak は一切触らない。ここを更新すると、問題集で気軽に解き直すたびに /review の出題スケジュールが動いてしまう。「練習は履歴にだけ残る」を不変条件にした。
認証情報の隔離。 Tursoの接続情報は server/utils/learnDb.ts に閉じ込め、値をログ・エラー・レスポンスに出さない。APIは全ルート先頭の !import.meta.dev ガードで本番404、そもそもSSG本番にはNitroサーバー自体が乗らないので二重に安全、という整理にした。
UI側は LessonQuiz.vue を1本書いて、レッスン記事のMDC埋め込みと /lessons/practice ページで共用する。記事側はMarkdown末尾にこの1行を足すだけで動く。
::lesson-quiz{:concepts='["mdx-playground/practice-sm2-isolation", ...]'}
::
実装中の逸脱と試行錯誤
計画どおりに進まなかった箇所が実装ログの見どころで、今回は3つあった。
practiceページが描画されない。 pages/lessons.vue が既にある状態で pages/lessons/practice.vue を追加したら、practiceが lessons.vue の子ルート扱いになり、開いても中身が描画されなかった。Nuxtのネストルート仕様で、親に <NuxtPage> の出口がないと子は表示されない。lessons.vue を lessons/index.vue に移動して解決させた。ファイルを1枚足しただけのつもりが、既存ページの置き場所ごと変わるのがルーティング規約の怖いところ。
既存テストが公開URL規約で落ちる。 レッスン記事はdev専用・unpublishedなのに、OGメタタグの網羅テストとURL移行テストが公開記事と同じ規約を要求してきた。dev専用コンテンツは公開URL規約の対象外という判断で、og-meta-tags.test.ts のEXCLUDED_PATHSと url-migration.test.ts の走査対象に除外を足させた。
クイズブロックが記事の中間に埋もれる。 レッスンMDは同じ論点で複数ラウンド追記されていくので、素直に末尾へ ::lesson-quiz を出力すると、次のラウンド追記でブロックがファイル中間に埋もれる。quiz-server側の追記処理を「既存クイズブロックを一度剥がし、ラウンド追記後に末尾へ付け直す」方式にしてもらった。
MDCコンポーネント名の未定義描画も計画段階で潰してあった。::lesson-quiz の出力追加はコンポーネントの実装と表示確認が終わってからという順序を計画書に明記していたので、未定義コンポーネントで記事が壊れる事故は起きなかった。
検証は証拠ベースで確認した
「SM-2に干渉しない」は設計の約束であって、実装がそうなっている保証はない。そこでpracticeで5回答(curl 2件+ブラウザ実操作3件)させた前後で concepts 全行のスナップショットを取り、JSONが完全一致することを確認させた。1フィールドも動いていなかった。
- SM-2非干渉: practice 5回答の前後で concepts 全行(ease / interval_d / due_at / streak / last_seen)が完全一致
- 正解の非漏洩: questions APIの全件レスポンスに「correct」の出現0件(
grep -o | wc -lで計測) - Vitest: apps/web側に31件追加(lesson-quiz 24・lesson-quiz-client 7)全パス。quiz-server側29件も全パス
- カバレッジ: lesson-quiz-client.ts 100%
- Chrome DevToolsで実機確認:
/lessons/practice(9問)と記事埋め込み(4問)の両方で回答フロー・過去3回の○×バッジ・解説への逆引きリンクを目視、コンソールエラー0
成果はコミット bdba0ab4(14ファイル・+1,167行)にまとまり、既存レッスンMD 2本への ::lesson-quiz 追記と、計画書への実施結果・残作業の追記まで含めて当日中にコミットした。
残作業
-
pnpm generateでの本番到達不能の最終確認(方針どおりリリース直前の1回だけ実施する) - quiz-server MCPの再起動(稼働中プロセスは旧形式でレッスンMDを保存するため、新dist反映まで手動で
::lesson-quizを追記する) - クイズ出題時のずんだもん音声通知フックの有効化(settings.jsonに追加済みだが、今セッションの起動時スナップショット外)
学び
- レビュー済みの計画書を「正」として渡すと、実装セッションは逸脱の記録に集中できる。計画からズレた3箇所(ルーティング・テスト除外・追記方式)が、そのまま次回への教訓リストになった
- Nuxtで
pages/foo.vueとpages/foo/bar.vueを共存させると、barは子ルート扱いで描画されない。ページを後から増やす予定があるなら最初からfoo/index.vueに置く - 「干渉しない」「漏洩しない」系の不変条件は、前後スナップショットの突き合わせと出現数カウントで機械的に証明できる。目視レビューより速くて確実だった