- 子どもの学習時間とゲーム時間を管理するPWAを作りました。
- ブラウザの中で動くアプリなので、できるのは記録だけです。
- 残高を通帳のように見せて、判断は本人に返す作りにしました。
- タイマーを画面の中に置いたら、ホームに戻った瞬間に止まりました。時間を数える場所は、画面の外にありました。
1. ゲームを止める機能は、はじめから作れなかった
最近、子どもの学習時間とゲーム時間を管理するアプリを作りました。
勉強した分だけゲームの時間が増える、というよくある約束事を、紙のメモではなくスマホの画面でやるためのものです。
ホーム画面に追加して使うPWA(Progressive Web App:プログレッシブウェブアプリ)にしました1。
止めるのではなく、記録係に振り切りました。
残高がいくらあって、今どれだけ使ったか。
それを本人がいつでも見られるようにする。
使いすぎを止めるのは、アプリではなく人間の役目という切り分けです。
この方針は、Playの画面にそのまま出ています。
選んだ時間が0になるとアラームが鳴り続けますが、画面をタップすると音だけが止まります。
ゲームはそのまま続けられて、超過した時間は -02:15 のようなマイナス表示で数え続ける。

とはいえ、ズルはできません。
というのも、すべての操作がログに残って、いつでも同じ画面で確認できるからです。
1.1. 残高という数値は、どこにも保存していない
残高は、その日までのログを全部合計した結果として出しています2。
現在の残高 = 定時加算 + Work加算 - Play消費 + 手動調整
残高を別の場所に保存して、記録のたびに足し引きするやり方もあります。
ただ、それをやると残高とログがずれたときに、どちらが正しいのか誰にも分からなくなる。
合計するだけなら、ずれようがありません。
定時加算は、毎日決まった分数が自動で足される仕組みです。
平日と休日で別の値を設定できます。
アプリを開いたときに、最後に付与した日の翌日から今日までを見て、足りない日ぶんだけまとめて作ります。
数日開かなかった日の分も遡って付きますし、同じ日に何度開いても二重には付きません。
設定画面には、残り時間を任意の値にリセットする機能もあります。
親が手で補正するための逃げ道ですが、これも adjust というログとして残ります。
こっそり増やすことはできない、という点では親も子も同じ扱い。

1.2. マイナスまで数える
残高はマイナスまで記録します。
使いすぎた翌日は、借金を返すところから始まる。
マイナスの間は新しいPlayを開始できません。
Workで加算するか、翌日の定時加算を待って0分以上に戻せば、また遊べます。
ここだけは、アプリが唯一「できない」と言う場所です。
Play消費は切り捨てで記録して、1分未満で終わった場合は消費なし、ログにも残しません。
早めに切り上げると得をする、という小さな仕掛けです。
Work完了時にもらえる分数は、学習時間から自動計算していません。
候補の中から本人が選びます。
30分やったら何分もらえるのかは親子の約束で決まる話で、アプリが決めることではないからです。

2. タイマーは、画面を離れると死ぬ
2.1. 時間を画面の中に置くと、画面を離れた瞬間に消える
最初、Playのカウントダウンは、Play画面のコンポーネントが自分で持っていました。
残り時間も、警告を出したかどうかも、そこに置いてあった。
すると、遊んでいる途中でホームや履歴を見に行くとカウントが止まります。
画面を切り替えると、React(リアクト)はその画面のコンポーネントを丸ごと破棄するからです。
持っていた状態も一緒に消えます。
そこで、Playのセッション状態を、画面より上のContext(コンテキスト)へ引き上げました。
画面はアンマウントされても、その外側にいるProviderは生き続けます。
時間を数えるのは画面の仕事ではない、という置き直しです。
ついでに、他の画面を見ている間は右下に半透明のミニタイマーを出すようにしました。
タップすればPlayに戻れます。
Play中は画面のスリープも止めます。
Screen Wake Lock APIという仕組みで、対応していない環境では黙って諦める作りです3。
2.2. setIntervalは、時間を数えていない
Workの学習時間のほうにも、似た勘違いがありました。
1秒ごとに呼ばれる setInterval の中で、カウンタに1ずつ足していたのです。
これだと、画面をロックしたりアプリを裏に回したりしたときに記録が実際より短くなります。
ブラウザは、見えていないページのタイマーを容赦なく間引くから4。
呼ばれた回数を数えているうちは、間引かれた分がそのまま消えます。
開始時刻を覚えておいて、終了時に現在時刻から引く方式に変えました。
休憩していた時間はその都度差し引きます。
こうすると、タイマーが何回呼ばれたかは記録に関係なくなります。setInterval の役目は、画面の数字を書き換えることだけ。

3. 演出は、あとから足す飾りではなかった
3.1. ログインボーナスと、コモン・レア・エピック
見た目は、FPSゲームのHUD(Head-Up Display:ヘッドアップディスプレイ)を意識した暗い画面にしました。
勉強の記録という地味な行為に、ゲームの文法を借りてくる。
その日はじめて開いたときだけ、全画面で「LOGIN BONUS」の演出が出ます。
定時加算の分数がカウントアップして、「うけとる」で閉じる。
数日ぶりに開いたときの遡及分は演出しません。
今日の分だけを見せます。
毎日開く理由が、ログを付ける義務感の外側にもう一つ要ると思ったからです。
Work完了時に選ぶ加算候補も、ただの数字のボタンから、コモン・レア・エピックの3段階に見た目を分けました。
同じ+60分でも、エピックの色で出てくると選ぶ気持ちが変わる。
ボタンを押すと、指の当たった場所から光の粒が放射状に散って、音が鳴ります。
音は最初、Web Audio APIでその場で合成していました5。
短いサイン波を鳴らすだけなら音源ファイルが要らないので、読み込み待ちもアセット管理も発生しません。
あとから、ホームのタイル、Work開始、報酬の獲得など主要な操作だけをmp3に差し替えました。
この演出は画面ごとには書いていません。
アプリ全体を包む要素にクリックを1つだけ受けて、押されたのがボタンなら鳴らす。
画面を増やしても、演出の実装を足す必要がない置き方です。
3.2. カウントアップが、2回目から動かない
ホームの残高も、Workから戻ってきたときに数字が上がっていく演出にしました。
これが一番手こずったところです。
ホームは画面遷移で破棄されるので、戻ってきた時点では「増える前の値」を覚えていません。
そこで、まだ演出していない増減分をContext側に持たせました。
記録するたびにその値を積んで、ホームは現在の残高からそれを引いた数から数え上げ、終わったら消化する。
論理的な残高と、演出のための一時的な差分を、別の値として扱うわけです。
もう一段、開発中だけ起きる罠がありました。
Reactには開発時に副作用を意図的に2回実行するモードがあります6。
壊れやすい書き方を早めに見つけるための仕掛けです。
このとき、前の値を覚えておく変数を副作用の本体で書き換えていると、2回目の実行では前の値がすでに現在値と同じになっていて、アニメーションが一瞬で終わってしまいます。
直し方は、変数の更新をアニメーションの進行中コールバックの内側だけに限ることでした。
本番のビルドでは2回実行されないので、この不具合は開発中にしか出ません。
動かして初めて分かる類のもので、テストでは拾えなかった。

4. レンタルサーバーに置くだけのPWA
技術の構成は、Vite(ヴィート)にReactとTypeScript、演出にFramer Motionです7。
PWA化のプラグインを入れると、ホーム画面に追加するためのmanifestと、オフラインで開くためのService Workerを自動で作ってくれます8。
配布は、借りているレンタルサーバーのサブディレクトリにファイルを置くだけ。
ビルドの出力先をそのサブディレクトリと同じ名前にして、リンクの参照先を相対パスにしておくと、出てきたフォルダの中身をそのままFTPでアップロードするだけで動きます。
ビルドの設定を1行変えるかどうかの話ですが、ここを絶対パスのままにすると、サブディレクトリに置いた瞬間に真っ白な画面になります9。
データはIndexedDBに入れています10。
ブラウザの中の、その端末だけのデータベース。
サーバーもログインも持たないので、家族の記録が外に出ることはありません。
そのかわり、機種変更やブラウザのデータ削除で消えます11。
設定画面からJSONで書き出して読み込める逃げ道は用意しました。
残高の計算と定時加算の判定は、UIから切り離した純粋な関数にして、自動テストを10件書いてあります。
一方で、画面の見え方や演出は自動テストの外です。
休日の判定は今のところ土日だけで、祝日には対応していません。

作りながら何度も戻ってきたのは、「止められないなら何をするのか」という最初の割り切りでした。
強制する機能がないと決めた瞬間に、アラームは音だけ止まればいいし、残高はマイナスまで数えていいし、手動調整もログに残せばいい、と細かい仕様が次々に決まっていく。
できないことを認めるところから設計が始まる、というのは、ソフトウェアに限った話でもなさそうです。
- PWAは、ウェブアプリをホーム画面へインストールして、ほとんどの端末でネイティブアプリと同じようにOSへ統合された形で開ける仕組みです。 – ウェブアプリのインストールとアンインストール | MDN
- 現在の状態を保存せず、起きた出来事の記録を積み上げて必要なときに計算し直す設計は、イベントソーシングと呼ばれます。 – Event Sourcing
- Screen Wake Lock APIは、表示中のページが画面の消灯やロックを一時的に防ぐためのAPIで、地図の案内やレシピの表示のように画面を見続ける用途が想定されています。 – Screen Wake Lock API | MDN
- Chromeは背景タブのタイマーの最小間隔を10秒まで広げ、Firefoxのデスクトップ版は1秒に制限します。 – Window: setTimeout() method | MDN
- OscillatorNodeは、サイン波などの周期的な波形をその場で作る音源ノードで、何も指定しなければサイン波になります。 – OscillatorNode | MDN
- StrictModeは開発時だけ、すべてのエフェクトに対してセットアップと後始末を1往復多く走らせます。本番のビルドでは実行されません。 – StrictMode – React
- Framer Motionは2025年に独立したプロジェクトとなってMotionへ改称し、パッケージ名も framer-motion から motion へ移りました。 – Motion for React
- vite-plugin-pwaは、ウェブアプリマニフェストの生成と挿入、WorkboxによるService Workerの生成と登録を、設定なしでも行うViteのプラグインです。 – Vite Plugin PWA
- Viteのbaseオプションは配信時のベースとなるパブリックパスで、埋め込み配置のためには空文字列か ./ を指定します。 – 共通オプション | Vite
- IndexedDBは、ブラウザーの中に構造化したデータをまとまった量で保存できる、トランザクションに対応したデータベースです。 – IndexedDB API | MDN
- IndexedDBに置いたデータは既定ではベストエフォートの扱いで、ブラウザーが空き容量を必要としたときに通知なく削除されることがあります。 – ブラウザーのストレージ割り当てと削除基準 | MDN