メインコンテンツへスキップ
記事一覧に戻る
programmingadalab focus 開発記 (4/5)

ポモドーロタイマーを setInterval で作ってはいけない — タイムスタンプ方式の状態設計

Web アプリのタイマーをカウントダウン変数で実装すると、タブのバックグラウンド化やリロードで簡単に壊れます。「開始時刻だけを持ち、残り時間は毎回計算する」タイムスタンプ方式の設計と、複数タブ同期・リロード復元の実装を adalab focus の実例で解説します。

2026-06-1510 min readAdabana Saki
-
JavaScriptTypeScriptタイマーポモドーロzustand状態管理PWAadalab focus
シリーズ中級者〜上級者

adalab focus 開発記

学習管理 PWA「adalab focus」の開発で得た知見を解説。ローカルファースト設計、オフライン同期、サーバーレス認証、タイマーの状態設計、ブラウザ拡張との連携まで

進捗4 / 5

ポモドーロタイマーを setInterval で作ってはいけない — タイムスタンプ方式の状態設計

adalab focus 開発記の第 4 回。今回はポモドーロタイマーの実装です。

「25 分のカウントダウンなんて setInterval で 1 秒ずつ引くだけでは?」と思った方にこそ読んでほしい記事です。私も最初はそう思っていましたが、その実装はブラウザの現実の前に必ず壊れます。

カウントダウン変数方式が壊れる3つの現実

TypeScript

これが壊れる理由:

Text

つまり「経過時間を変数に貯める」方式は、JS が止まらないことを前提にしています。その前提はブラウザでは成立しません。

タイムスタンプ方式:状態は「いつ始めたか」だけ

発想を逆転させます。残り時間は状態ではなく、毎回計算する派生値にするのです。

TypeScript

残り時間は「今何時か」を入れて計算します:

TypeScript

setInterval の役割は再描画のトリガーだけになります。間引かれようがスリープしようが、次に動いた瞬間に Date.now() から正しい残り時間が出てきます。

TSX
Text

副産物として、リロード復元がタダで手に入ります。startedAt を localStorage に保存しておけば、リロード後も「開始時刻と今の差」から正確な残り時間が復元できるからです。

落とし穴①:復元した瞬間にアラームが鳴る

ここからは実際に踏んだバグの話です。タイマー実行中にタブを閉じ、数時間後に開き直すと——開いた瞬間にアラームが鳴ります。

原因は素直で、復元した状態の残り時間を計算すると 0 になっており、「完了監視」がそれを検知してしまうのです。さらにうちのタイマーには自動サイクル機能(作業→休憩→作業を自動で回す)があったので、放置すると永遠に鳴り続けるアプリが完成していました。

対策は「アプリを閉じている間に終わったタイマーは、音を鳴らさず静かに精算する」です。復元時に判定を 1 つ挟みます:

TypeScript

アラームというのは「完了の瞬間に立ち会っている人」のためのものです。閉じている間の完了は、もう過去の出来事として処理する方が正しい挙動でした。

落とし穴②:PWA + ブラウザタブで二重に鳴る

adalab focus は PWA なので、「インストールしたアプリ」と「普通のタブ」を同時に開けてしまいます。するとそれぞれが独立したタイマーを持ち、アラームが 2 回鳴り、学習記録も 2 重に保存されるという事故が起きました。

対策は 2 段構えです。

1. storage イベントでタブ間の状態を同期する

localStorage への書き込みは他のタブに storage イベントとして届きます。これでタイマー状態を全タブで 1 つに保ちます:

TypeScript

2. 完了処理はロックを取ったタブだけが行う

状態が同期されると、今度は全タブが同時に「残り 0 秒」を検知します。完了処理(アラーム・記録)は localStorage を簡易ロックにして早い者勝ちにしました:

TypeScript

phase + startedAt の組がイベントの一意 ID になっているのがポイントです。同じ完了を 2 つのタブが処理することはなくなります。

落とし穴③:自動サイクルは「無人運転」を検知して止める

自動サイクル(作業完了→休憩を自動開始→また作業…)は便利ですが、ユーザーがアプリを忘れて離席すると誰もいない部屋で 30 分おきに鳴り続けます。

これは技術というより設計の問題で、「最後にユーザーが操作した時刻」を持っておき、長時間無操作のままサイクルが回り続けていたら自動進行を止めるようにしました:

TypeScript

タイマーづくりの後半戦は、アルゴリズムではなく**「人間はアプリを閉じるし、忘れるし、2 つ開く」という前提**との戦いでした。

まとめ

原則理由
残り時間は持たない。開始時刻から毎回計算interval の間引き・スリープに耐える
setInterval は再描画のトリガーに過ぎない止まっても状態が壊れない
閉じている間の完了は無音で精算起動した瞬間に鳴るアラームは誰も求めていない
タブ間は storage イベントで同期 + 完了はロックで1回PWA とタブの同時起動に耐える
無人の自動サイクルは止めるアプリは忘れられるものとして設計する

次回は最終回、Web アプリとブラウザ拡張機能の連携です。フォーカス中だけ YouTube をブロックする拡張「adalab shield」と、postMessage プロトコルでつないだ話を書きます。

リンク