個人用の暗記アプリをPHPで作りました。ファイル数52、合計12,258行。コードのほとんどはClaudeに書いてもらいました。
この規模になると、指示の出し方より「どこで切るか」のほうが効いてきます。一度に渡す範囲が広すぎると関係ないところが書き換わりますし、狭すぎると全体の整合が取れなくなります。実際に作りながら決めていった分け方と、つまずいた箇所を残しておきます。
何を作ったのか
基本情報技術者試験の学習用に、間隔反復(スペースド・リピティション)で復習タイミングを管理するカードアプリです。既製品を使わず自作したのは、出題間隔の調整と、用語どうしの相互リンクを自分の都合で組みたかったからです。
デッキは入れ子にできます。科目A・科目Bを親にして、その下にセキュリティ、ネットワーク、アルゴリズム、マネジメント、ストラテジがぶら下がる構造です。カードは画像も貼れます。
中心にあるのは出題間隔を決める部分です。カードは4つの状態を持ち、正解・不正解の履歴で次に出る日が決まります。
カードの4状態。何度も間違えたカードは自動で隔離されます
もうひとつ手をかけたのがオフライン対応です。飛行機に乗るときなど、通信できない場所で解答だけためておき、着いてからまとめて送信します。この機能が、結果的に一番の難所になりました。
作った環境
再現の参考になるよう、環境を書いておきます。
- AI — Claude Code
- 言語 — PHP 8.3(
match式やreadonlyを使っているため 8.1 以上が必要です) - データベース — MariaDB 10.5
- サーバー — エックスサーバー。共用のレンタルサーバーで、Node.js もビルド環境もありません
- フロント — 素のJavaScript。フレームワークもバンドラも使っていません
「レンタルサーバーに置くだけで動く」ことを条件にしたので、ビルド工程がありません。この制約が、後述する二重実装の原因にもなっています。
一番難しかったのは、同じ計算を2回書くことでした
オフラインで解答できるということは、次にそのカードがいつ出るかを端末側でも計算できなければいけないということです。サーバーに問い合わせられないので当然です。
結果として、間隔を決めるロジックがPHPとJavaScriptに1つずつ、同じものが2つ存在しています。lib/scheduler.php のコメントにはこう書いてあります。
注意:
scheduler-client.jsのpreviewIntervals()と完全に同じロジックにすること。
これはAIと進めるうえで相性が悪い状況です。片方だけ直すと、オフラインで見えた予定日と、同期後にサーバーが返す予定日がズレます。しかもズレたことに気づけません。エラーにならず、ただ数字が違うだけだからです。
対策として、この2つのファイルは必ずセットでClaudeに渡すようにしました。片方だけ開いて修正を頼まない、という運用でしのいでいます。設計としては敗北していますが、ビルド工程を持たない以上、共通化する手段がありませんでした。
海外に行って、二重実装のツケを払いました
この二重実装が実際に事故になったのが、フランスに滞在していたときでした。
このアプリの「1日」は日本時間で固定しています。サーバー側は date_default_timezone_set('Asia/Tokyo') を入れてあり、データベースの日時もすべて日本時間の文字列です。ところがJavaScript側は、端末のローカル時刻から日付を組み立てていました。
現地の21時は、日本時間では翌日の朝4時です。つまり現地21時から翌4時までの7時間、端末とサーバーが別々の「今日」を見ていました。その間に解答したカードは前日扱いで記録され、ホーム画面の残り枚数が減りません。解いているのに数字が動かない、という状態です。
原因は単純で、サーバー側は日本時間に固定していたのに、クライアント側にその固定が入っていなかっただけです。同じロジックのはずの2つが、同じではなかった。まさに前節で書いた「気づけないズレ」が、国境を越えた瞬間に表面化しました。
直し方も単純です。クライアント側で端末のタイムゾーンを一切使わないようにしました。時刻に9時間を足したうえで、getUTCFullYear() や Date.UTC() のようなUTC系の関数だけで日付を組み立てます。こうすると端末がどこにあっても、日本時間の壁掛け時計を見ているのと同じ結果になります。
あわせて、端末のタイムゾーンが日本時間とズレているときだけ、次に学習分が切り替わる時刻を現地時間でも併記するようにしました。海外にいると「現地の何時に翌日分が来るのか」が直感的に分からないためです。バグは直りましたが、仕様として分かりにくいことは変わらないので、表示で補っています。
差分同期は、一度判断を間違えました
同期の設計も二転三転しています。
最初は「前回の同期以降に変わったものだけ返す」という素直な差分にしていました。ところが取りこぼしが起きました。差分では削除が伝わらないのです。サーバーで消したカードが端末に残り続けます。
そこで全量返す方式へ切り替えました。これで取りこぼしは消えましたが、今度は重すぎました。カード本文を毎回全部送るので、1回あたり2MB(gzip後で0.5MB)になっていました。
最終的に落とし所を決めました。
- カード本文は差分。前回以降に更新されたものだけ返します
- 学習状態(state / due / ease など)は全件返します
- 現存する全IDの一覧を毎回返し、端末側でそれに無いものを掃除します
本文は重いが変化が少なく、学習状態は軽いが毎回変わる。性質が違うものを同じ方式で運ぼうとしたのが間違いでした。分けた結果、数十KBまで落ちています。
削除の検出は、差分をやめるのではなくIDの一覧を別枠で渡すことで解決しました。「何があるか」だけなら軽いからです。
1秒のズレがバグになりました
細かい話ですが、印象に残っているので書いておきます。
次回の出題日を「◯日後」と表示する部分で、現在時刻との差を取っていました。これだと処理が秒をまたいだ瞬間に、24時間後が「23時間59分後」になります。表示が「1日後」ではなく「23時間後」になってしまう。
復習状態のカードは interval_days * 86400 で確定値を返すよう直しました。現在時刻を挟まず、確定している値から計算するという当たり前の話です。
同期側でも似た修正を入れています。同じ秒に複数の解答が入ると処理順が決まらず、計算結果が毎回変わっていました。解答時刻で並べたあと、同秒のものは端末側で振ったIDで安定ソートするようにしています。
タイムゾーンの件も含めて、どれも時刻を比較に使うと境界で壊れるという同じ形をしています。AIは指示どおりに時刻の差分を取りますが、境界条件までは気にしません。ここは人間が見るべき場所でした。
画面は200行を超えたら黄色信号です
8枚の画面は84行から453行、平均213行になりました。この幅には理由があります。
200行前後までは、ファイル1枚をそのまま渡して「ここを直して」で通ります。Claudeが全体を把握したまま部分を書き換えてくれます。
問題は453行の cards.php でした。カードの一覧・検索・編集・削除を1枚に詰め込んだ結果、修正を頼むたびに関係ない箇所が書き換わるようになりました。「検索条件の挙動を変えて」と頼むと、編集フォームのバリデーションが黙って書き換わっています。
これは能力の問題ではなく、こちらの切り方の問題です。1枚のファイルに4つの関心事が入っていれば、どれを触っていいのか判断する材料がありません。分けていれば起きませんでした。
一番大きいファイルが、一番どうでもいい
行数で並べると、上位はこうなります。
- 968行 —
tools/seed_cards/p1_kamokub_practice.php - 911行 —
tools/seed_cards/p3_term_cards.php - 573行 —
tools/seed_cards/p1_kamokub_v4_dp_str_io.php - 525行 —
lib/helpers.php
上位3つはすべて tools/seed_cards/ の中身、つまり初期データを流し込むだけの使い捨てスクリプトです。ロジックはほぼなく、配列が延々と続いています。
ここが分割の勘所だと思います。行数は、分割すべきかどうかの指標になりません。968行あっても中身が配列の羅列なら、そのまま生成してもらって二度と開かなくて済みます。逆に453行でも4つの関心事が混ざっていれば、触るたびに事故ります。
見るべきは長さではなく、そのファイルが答えている問いがいくつあるかでした。
クラスにしたのは1つだけです
52ファイルのうち、クラスとして書いたのは lib/scheduler.php だけです。あとは全部ただの関数の集まりにしました。
Schedulerのメソッドは16個ありますが、外から呼べるのは4つだけで、残り12個はprivateにしています。handleNew handleLearning handleReview handleRelearning と状態ごとに処理が分かれていて、内部の分岐が多いためです。
ここだけクラスにしたのは、設計思想ではなく実務上の理由です。内部が複雑なものほど、外から触れる面を小さく固定しておいたほうが、Claudeへの指示が短くなります。「answer() の戻り値は変えずに、学習中カードの間隔計算だけ直して」と頼めば、影響範囲がその場で確定します。
逆に helpers.php は29個の関数がフラットに並んだだけで、クラスにしていません。セッション、エスケープ、CSRF、日付整形、フラッシュメッセージと、関係のないものが同居しています。正直これは雑多な置き場になっていて、きれいではありません。ただ「共通で使う小物」という1つの問いには答えているので、事故は起きていません。
まとめ
規模の大きいコードをAIと書くとき、効いたのは次の3つでした。
- 全ファイルの冒頭を揃える。前提の説明が毎回不要になります
- 1ファイルが答える問いを1つに絞る。行数ではなく関心事の数で切ります
- 内部が複雑なものだけ、外から触れる面を小さく固定する
逆に、任せきると危ないのは次の2つでした。
- 同じロジックを2箇所に持つ構造。片方だけ直ってもエラーにならず、気づけません
- 時刻を比較に使う処理。境界条件やタイムゾーンは、指示しないと考慮されません
どれも新しい話ではありません。ただ、人間だけで書いていた頃は「あとで直せばいい」で済んでいたものが、AIと書くと即座に事故として返ってきます。切り方の雑さが、そのまま出力の雑さになります。