ポモドーロタイマーを setInterval で作ってはいけない — タイムスタンプ方式の状態設計
Web アプリのタイマーをカウントダウン変数で実装すると、タブのバックグラウンド化やリロードで簡単に壊れます。「開始時刻だけを持ち、残り時間は毎回計算する」タイムスタンプ方式の設計と、複数タブ同期・リロード復元の実装を adalab focus の実例で解説します。
adalab focus 開発記
学習管理 PWA「adalab focus」の開発で得た知見を解説。ローカルファースト設計、オフライン同期、サーバーレス認証、タイマーの状態設計、ブラウザ拡張との連携まで
ポモドーロタイマーを setInterval で作ってはいけない — タイムスタンプ方式の状態設計
adalab focus 開発記の第 4 回。今回はポモドーロタイマーの実装です。
「25 分のカウントダウンなんて setInterval で 1 秒ずつ引くだけでは?」と思った方にこそ読んでほしい記事です。私も最初はそう思っていましたが、その実装はブラウザの現実の前に必ず壊れます。
カウントダウン変数方式が壊れる3つの現実
これが壊れる理由:
つまり「経過時間を変数に貯める」方式は、JS が止まらないことを前提にしています。その前提はブラウザでは成立しません。
タイムスタンプ方式:状態は「いつ始めたか」だけ
発想を逆転させます。残り時間は状態ではなく、毎回計算する派生値にするのです。
残り時間は「今何時か」を入れて計算します:
setInterval の役割は再描画のトリガーだけになります。間引かれようがスリープしようが、次に動いた瞬間に Date.now() から正しい残り時間が出てきます。
副産物として、リロード復元がタダで手に入ります。startedAt を localStorage に保存しておけば、リロード後も「開始時刻と今の差」から正確な残り時間が復元できるからです。
落とし穴①:復元した瞬間にアラームが鳴る
ここからは実際に踏んだバグの話です。タイマー実行中にタブを閉じ、数時間後に開き直すと——開いた瞬間にアラームが鳴ります。
原因は素直で、復元した状態の残り時間を計算すると 0 になっており、「完了監視」がそれを検知してしまうのです。さらにうちのタイマーには自動サイクル機能(作業→休憩→作業を自動で回す)があったので、放置すると永遠に鳴り続けるアプリが完成していました。
対策は「アプリを閉じている間に終わったタイマーは、音を鳴らさず静かに精算する」です。復元時に判定を 1 つ挟みます:
アラームというのは「完了の瞬間に立ち会っている人」のためのものです。閉じている間の完了は、もう過去の出来事として処理する方が正しい挙動でした。
落とし穴②:PWA + ブラウザタブで二重に鳴る
adalab focus は PWA なので、「インストールしたアプリ」と「普通のタブ」を同時に開けてしまいます。するとそれぞれが独立したタイマーを持ち、アラームが 2 回鳴り、学習記録も 2 重に保存されるという事故が起きました。
対策は 2 段構えです。
1. storage イベントでタブ間の状態を同期する
localStorage への書き込みは他のタブに storage イベントとして届きます。これでタイマー状態を全タブで 1 つに保ちます:
2. 完了処理はロックを取ったタブだけが行う
状態が同期されると、今度は全タブが同時に「残り 0 秒」を検知します。完了処理(アラーム・記録)は localStorage を簡易ロックにして早い者勝ちにしました:
phase + startedAt の組がイベントの一意 ID になっているのがポイントです。同じ完了を 2 つのタブが処理することはなくなります。
落とし穴③:自動サイクルは「無人運転」を検知して止める
自動サイクル(作業完了→休憩を自動開始→また作業…)は便利ですが、ユーザーがアプリを忘れて離席すると誰もいない部屋で 30 分おきに鳴り続けます。
これは技術というより設計の問題で、「最後にユーザーが操作した時刻」を持っておき、長時間無操作のままサイクルが回り続けていたら自動進行を止めるようにしました:
タイマーづくりの後半戦は、アルゴリズムではなく**「人間はアプリを閉じるし、忘れるし、2 つ開く」という前提**との戦いでした。
まとめ
| 原則 | 理由 |
|---|---|
| 残り時間は持たない。開始時刻から毎回計算 | interval の間引き・スリープに耐える |
| setInterval は再描画のトリガーに過ぎない | 止まっても状態が壊れない |
| 閉じている間の完了は無音で精算 | 起動した瞬間に鳴るアラームは誰も求めていない |
| タブ間は storage イベントで同期 + 完了はロックで1回 | PWA とタブの同時起動に耐える |
| 無人の自動サイクルは止める | アプリは忘れられるものとして設計する |
次回は最終回、Web アプリとブラウザ拡張機能の連携です。フォーカス中だけ YouTube をブロックする拡張「adalab shield」と、postMessage プロトコルでつないだ話を書きます。