Next.js + react-timer-hookでポモドーロタイマーを作る
最近、『ポモドーロテクニック』という時間管理手法を使っています。
ご存知ない方のために、Wikipediaを引用します。
このテクニックは2009年に出版されたシリロの著書『The Pomodoro Technique』(どんな仕事も「25分+5分」で結果が出る ポモドーロ・テクニック入門)や、自身の公式サイト内で紹介されている。具体的な手順は以下の通りである。
1.達成しようとするタスクを選ぶ
2.キッチンタイマーで25分を設定する
3.タイマーが鳴るまでタスクに集中する
4.少し休憩する(5分程度)
5.ステップ2 - 4を4回繰り返したら、少し長めに休憩する(15分 - 30分)
ポモドーロの途中で急用が入りタスクが中断された場合は、そのポモドーロは終了とみなし、はじめから新しいポモドーロを開始する。メールをチェックしたくなったり、誰かに連絡する用事を急に思い出したり、他人を気にしたりしてタスクを中断することは「内的中断」であり、さほど重要でないことをやっており、そもそもの目標設定が適切でないことに原因があるとしている。
要するに、作業時間のセットと休憩時間のセットを繰り返し行えるタイマーです。
私はこちらのサイトのものをよく使っています。
デザインがおしゃれで素敵です。
一方で、使っていて休憩時間は可変で設定したいと思うときがあり、勉強も兼ねて自作することにしました。
(ちなみに、こちらのサイトだと作業時間や休憩時間も自由に設定できます。)
作ったもの
以下はgifのためカクついていますが実際は滑らかに動きます。また、タイマーが切り替わる際には効果音が鳴ります。
休憩時間調整ボタンで時間を調整できます。
(今回は切り替えのデモ用に6秒だけにしています。)
技術スタック
- Next.js:15.1.7
- React:19.0.0
- Tailwindcss
- shadcn/ui
- react-timer-hook
コード全文
src/components/Timer.tsx
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useTimer } from "react-timer-hook";
// タイマー関連の定数(秒単位)
// ※ デモ用に短くしていますが、本番では 25*60, 5*60 等に戻してください。
const WORK_DURATION = 0.1 * 60; // テスト用:約6秒
const DEFAULT_BREAK_DURATION = 0.1 * 60; // テスト用:約6秒
const LONG_BREAK_DURATION = 20 * 60; // 4セット後の長め休憩(20分)
const MIN_BREAK_DURATION = 0.1 * 60; // 休憩時間下限(10分)// テスト用:約6秒
const MAX_BREAK_DURATION = 59 * 60; // 休憩時間上限(60分)
/**
* 簡易的なビープ音再生
*/
function playBeep() {
const AudioContext =
window.AudioContext || (window as any).webkitAudioContext;
const audioCtx = new AudioContext();
const oscillator = audioCtx.createOscillator();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(440, audioCtx.currentTime);
oscillator.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.5);
}
const Timer: React.FC = () => {
// モード管理: "work" または "break"
const [mode, setMode] = useState<"work" | "break">("work");
const [currentSetCount, setCurrentSetCount] = useState<number>(0);
const [breakDuration, setBreakDuration] = useState<number>(
DEFAULT_BREAK_DURATION
);
// モード開始時の秒数を保持して進捗バーに利用
const [initialTimer, setInitialTimer] = useState<number>(WORK_DURATION);
// 現在のモードに応じたタイマーの初期秒数を返す関数
const getDuration = () => (mode === "work" ? WORK_DURATION : breakDuration);
// expiryTimestamp を生成するヘルパー
const getExpiryTimestamp = (duration: number): Date => {
return new Date(Date.now() + duration * 1000);
};
// useTimer を利用してタイマー処理を実装
const { seconds, minutes, isRunning, start, pause, restart } = useTimer({
expiryTimestamp: getExpiryTimestamp(getDuration()),
autoStart: false,
onExpire: () => handleExpire(),
});
// モード切替時に、初期値を更新
useEffect(() => {
if (mode === "work") {
setInitialTimer(WORK_DURATION);
} else {
setInitialTimer(breakDuration);
}
// ※ モード切替時はユーザー操作待ち(autoStart:false)
}, [mode, breakDuration]);
// タイマー期限切れ時の処理
const handleExpire = () => {
playBeep();
if (mode === "work") {
const newSetCount = currentSetCount + 1;
setCurrentSetCount(newSetCount);
if (newSetCount % 4 === 0) {
setBreakDuration(LONG_BREAK_DURATION);
}
setMode("break");
// 休憩モード開始時は breakDuration を初期値としてセット
setInitialTimer(breakDuration);
restart(getExpiryTimestamp(breakDuration), false);
} else {
setMode("work");
setInitialTimer(WORK_DURATION);
restart(getExpiryTimestamp(WORK_DURATION), false);
}
};
// 休憩モード中に休憩時間調整ボタンを押したときの処理
const adjustBreakTime = (delta: number) => {
if (mode !== "break") return;
const deltaSec = delta * 60;
// 現在の残り時間を計算
const currentRemaining = minutes * 60 + seconds;
let newRemaining = currentRemaining + deltaSec;
if (newRemaining < MIN_BREAK_DURATION) newRemaining = MIN_BREAK_DURATION;
if (newRemaining > MAX_BREAK_DURATION) newRemaining = MAX_BREAK_DURATION;
// 更新後の休憩時間を breakDuration として保持(次回の休憩初期値にも使用)
setBreakDuration(newRemaining);
setInitialTimer(newRemaining);
// 現在のタイマーを再起動して調整後の残り時間に更新
restart(getExpiryTimestamp(newRemaining), false);
};
// 進捗バーの値計算: (初期値 - 残り) / 初期値 * 100
const currentRemaining = minutes * 60 + seconds;
const progressValue = initialTimer
? Math.min(
100,
Math.max(0, ((initialTimer - currentRemaining) / initialTimer) * 100)
)
: 0;
// 時刻表示用のフォーマッタ
const formatTimeCustom = (m: number, s: number) => {
const mm = m.toString().padStart(2, "0");
const ss = s.toString().padStart(2, "0");
return `${mm}:${ss}`;
};
// 各ボタンの操作ハンドラー
const handleStart = () => {
if (!isRunning) start();
};
const handlePause = () => {
if (isRunning) pause();
};
const handleStop = () => {
pause();
setMode("work");
setCurrentSetCount(0);
setBreakDuration(DEFAULT_BREAK_DURATION);
setInitialTimer(WORK_DURATION);
restart(getExpiryTimestamp(WORK_DURATION), false);
};
const handleCountReset = () => {
setCurrentSetCount(0);
};
// 表示用ラベル
const label = !isRunning
? mode === "work"
? "作業開始"
: "休憩開始"
: mode === "work"
? "作業中"
: "休憩中";
return (
<div
className={`flex flex-col items-center justify-center min-h-screen px-4
${
mode === "work"
? "bg-gradient-to-b from-orange-400 to-orange-200"
: "bg-gradient-to-b from-sky-400 to-sky-200"
}`}>
{/* 進捗バー */}
<Progress
value={progressValue}
className="w-full max-w-96 mb-6 bg-white"
/>
{/* タイマー表示 */}
<div className="relative flex flex-col items-center justify-center mb-16">
<div className="text-6xl font-bold text-white">
{formatTimeCustom(minutes, seconds)}
</div>
<div className="text-xl text-white">{label}</div>
</div>
{/* ボタン表示:未動作の場合はスタート、動作中は一時停止・停止 */}
<div className="flex space-x-4 mb-8">
{!isRunning ? (
<Button
variant="default"
onClick={handleStart}
className="text-xl w-24 h-12 rounded-full">
▶
</Button>
) : (
<>
<Button
variant="default"
onClick={handlePause}
className="text-xl w-24 h-12 rounded-full">
Ⅱ
</Button>
<Button
variant="destructive"
onClick={handleStop}
className="text-xl w-24 h-12 rounded-full">
■
</Button>
</>
)}
</div>
{/* 達成済みのセット数表示 */}
<p className="my-2 text-lg text-black">
達成済みのセット数: {currentSetCount}
</p>
{/* カウントリセットボタン */}
<Button variant="ghost" onClick={handleCountReset} className="mb-4">
カウントリセット
</Button>
{/* 休憩モード中のみ表示される休憩時間調整領域(固定高さ) */}
<div className="flex flex-col items-center mt-6 h-32">
{mode === "break" && (
<>
<p className="mb-2 text-black">休憩時間調整</p>
<div className="flex space-x-2">
<Button variant="ghost" onClick={() => adjustBreakTime(-5)}>
-5分
</Button>
<Button variant="ghost" onClick={() => adjustBreakTime(-1)}>
-1分
</Button>
<Button variant="ghost" onClick={() => adjustBreakTime(1)}>
+1分
</Button>
<Button variant="ghost" onClick={() => adjustBreakTime(5)}>
+5分
</Button>
</div>
</>
)}
</div>
</div>
);
};
export default Timer;
src/app/page.tsx
import Timer from "@/components/Timer";
export default function Home() {
return (
<div>
<Timer />
</div>
);
}
解説
何点か解説します。
react-timer-hook
react-timer-hookというライブラリを使うことで、タイマーやストップウォッチの機能を楽に実装することができます。
インストール
npm install --save react-timer-hook
今回はタイマーの機能を使います。
タイマーを初期化
タイマーを初期化します。
hoursやdaysもありますが、今回は分秒の範囲でのみ扱うので定義していません。
expiryTimestampでは終了時刻のDateオブジェクトを指定します。(後述)
autoStartをfalse に設定することで、タイマーは自動で開始せず、ユーザーが操作した際に start() メソッドを呼ぶことでスタートします。
onExpireではタイマーがゼロになった際に実行する処理を書きます。
const { seconds, minutes, isRunning, start, pause, restart } = useTimer({
expiryTimestamp: getExpiryTimestamp(getDuration()),
autoStart: false,
onExpire: () => handleExpire(),
});
expiryTimestamp: getExpiryTimestamp(getDuration()),について、expiryTimestampは仕様としてDateオブジェクトである必要があります。
つまり、残り秒数自体を直接カウントするのではなく、終了時刻を定義して、システムの現在時刻との差分を残り秒数として扱います。
例えば「60秒後に終了」とする場合、現在時刻(Date.now())に60000ミリ秒を加えた日時を終了時刻として定義します。
// 現在のモードに応じたタイマーの初期秒数を返す関数
const getDuration = () => (mode === "work" ? WORK_DURATION : breakDuration);
// expiryTimestamp を生成するヘルパー
const getExpiryTimestamp = (duration: number): Date => {
return new Date(Date.now() + duration * 1000);
};
タイマーの表示
タイマーを表示する際は、useTimerで定義したminutes, secondsを使います。
ただのnumberの値なので<div>タグ内で表示できます。
なお、時刻なので分秒がそれぞれ常に2桁で表示されるように、フォーマットしています。
{/* タイマー表示 */}
<div className="relative flex flex-col items-center justify-center mb-16">
<div className="text-6xl font-bold text-white">
{formatTimeCustom(minutes, seconds)}
</div>
<div className="text-xl text-white">{label}</div>
</div>
// 時刻表示用のフォーマッタ
const formatTimeCustom = (m: number, s: number) => {
const mm = m.toString().padStart(2, "0");
const ss = s.toString().padStart(2, "0");
return `${mm}:${ss}`;
};
タイマーがゼロになった際の処理
onExpireでhandleExpireを呼び出し、以下を行います。
- セット数の変更
- タイマーの時刻のリセット
- モード(work, break)の変更
// タイマー期限切れ時の処理
const handleExpire = () => {
playBeep();
if (mode === "work") {
const newSetCount = currentSetCount + 1;
setCurrentSetCount(newSetCount);
if (newSetCount % 4 === 0) {
setBreakDuration(LONG_BREAK_DURATION);
}
setMode("break");
// 休憩モード開始時は breakDuration を初期値としてセット
setInitialTimer(breakDuration);
restart(getExpiryTimestamp(breakDuration), false);
} else {
setMode("work");
setInitialTimer(WORK_DURATION);
restart(getExpiryTimestamp(WORK_DURATION), false);
}
};
タイマー終了時の音
元々はuse-soundもしくは使う予定だったのですが、うまくいかなかったので代わりにWeb Audio APIの標準機能で適当なビープ音を実装します。
ちなみに調べたら出てきたreact-howlerは最終更新が4年前のため使わない方が良さそう。
440Hzのサイン波の音を0.5秒鳴らしています。
// 簡易的なビープ音再生
function playBeep() {
const AudioContext =
window.AudioContext || (window as any).webkitAudioContext;
const audioCtx = new AudioContext();
const oscillator = audioCtx.createOscillator();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(440, audioCtx.currentTime);
oscillator.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.5);
}
mp3の読み込みについて
import finishSound from "/public/finish-sound.mp3";
こちらでモジュール '/public/finish-sound.mp3' またはそれに対応する型宣言が見つかりません。
とエラーが出ました。
私は知らなかったのですが、そもそもNext.jsでmp3を読み込むには別で設定が必要そうです。修正できたら追記します。
プログレスバー
shadcn/uiを使います。
npx shadcn@latest init
npm install @radix-ui/react-progress
valueに1〜100の値を入力すると進捗バーが出ます。
{/* 進捗バー */}
<Progress
value={progressValue}
className="w-full max-w-96 mb-6 bg-white"
/>
三項演算子で、初期値が設定されている場合は進捗の%を計算します。
Math.minとMath.maxで必ず0〜100の値に収まるように制限します。
// 進捗バーの値計算: (初期値 - 残り) / 初期値 * 100
const currentRemaining = minutes * 60 + seconds;
const progressValue = initialTimer
? Math.min(
100,
Math.max(0, ((initialTimer - currentRemaining) / initialTimer) * 100)
)
: 0;
今回は直線のプログレスバーですが、参考元に寄せるなら、円形で自作したほうが良いですね。
ラベル表示の分岐
三項演算子が入れ子になっています。
// 条件 ? 真の場合の値 : 偽の場合の値
// 例:条件が true の場合は左側の値("成人")、false の場合は右側の値("未成年")が返されます。
const result = age >= 18 ? "成人" : "未成年";
具体的には以下のような分岐です。
タイマーの動作中でない
┣workモード:"作業開始"
┗workモードでは無い:"休憩開始"
タイマーの動作中
┣workモード:"作業中"
┗workモードでは無い:"休憩中"
// 表示用ラベル
const label = !isRunning
? mode === "work"
? "作業開始"
: "休憩開始"
: mode === "work"
? "作業中"
: "休憩中";
最後に
機能追加するとしたら、そのセットの作業内容の目標とその履歴が見れると便利そうです。
皆さんもポモドーロテクニックで効率向上を目指しましょう!
Discussion