Zenn

Next.js + react-timer-hookでポモドーロタイマーを作る

2025/02/17に公開

最近、『ポモドーロテクニック』という時間管理手法を使っています。

ご存知ない方のために、Wikipediaを引用します。

このテクニックは2009年に出版されたシリロの著書『The Pomodoro Technique』(どんな仕事も「25分+5分」で結果が出る ポモドーロ・テクニック入門)や、自身の公式サイト内で紹介されている。具体的な手順は以下の通りである。
1.達成しようとするタスクを選ぶ
2.キッチンタイマーで25分を設定する
3.タイマーが鳴るまでタスクに集中する
4.少し休憩する(5分程度)
5.ステップ2 - 4を4回繰り返したら、少し長めに休憩する(15分 - 30分)

ポモドーロの途中で急用が入りタスクが中断された場合は、そのポモドーロは終了とみなし、はじめから新しいポモドーロを開始する。メールをチェックしたくなったり、誰かに連絡する用事を急に思い出したり、他人を気にしたりしてタスクを中断することは「内的中断」であり、さほど重要でないことをやっており、そもそもの目標設定が適切でないことに原因があるとしている。

https://ja.wikipedia.org/wiki/ポモドーロ・テクニック

要するに、作業時間のセットと休憩時間のセットを繰り返し行えるタイマーです。

私はこちらのサイトのものをよく使っています。
デザインがおしゃれで素敵です。
https://pomodoro.lit-gallery.com/

一方で、使っていて休憩時間は可変で設定したいと思うときがあり、勉強も兼ねて自作することにしました。

(ちなみに、こちらのサイトだと作業時間や休憩時間も自由に設定できます。)
https://www.oh-benri-tools.com/tools/time/pomodoro

作ったもの

以下はgifのためカクついていますが実際は滑らかに動きます。また、タイマーが切り替わる際には効果音が鳴ります。
休憩時間調整ボタンで時間を調整できます。
(今回は切り替えのデモ用に6秒だけにしています。)

技術スタック

  • Next.js:15.1.7
  • React:19.0.0
  • Tailwindcss
  • shadcn/ui
  • react-timer-hook

コード全文

src/components/Timer.tsx
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
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

今回はタイマーの機能を使います。
https://www.npmjs.com/package/react-timer-hook
https://dev.classmethod.jp/articles/running-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の標準機能で適当なビープ音を実装します。
https://www.npmjs.com/package/use-sound

ちなみに調べたら出てきたreact-howlerは最終更新が4年前のため使わない方が良さそう。
https://www.npmjs.com/package/react-howler

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を読み込むには別で設定が必要そうです。修正できたら追記します。
https://zenn.dev/takashiaihara/articles/b7ec54065501a2
https://tech.ymkokh.com/entry/2023/01/20/215355

プログレスバー

shadcn/uiを使います。

npx shadcn@latest init
npm install @radix-ui/react-progress

https://ui.shadcn.com/docs/components/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;

今回は直線のプログレスバーですが、参考元に寄せるなら、円形で自作したほうが良いですね。
https://zenn.dev/syu/articles/1cd30810c4363c
https://qiita.com/_L0e/items/8f6e8aa845681b67109d

ラベル表示の分岐

三項演算子が入れ子になっています。

// 条件 ? 真の場合の値 : 偽の場合の値
// 例:条件が true の場合は左側の値("成人")、false の場合は右側の値("未成年")が返されます。
const result = age >= 18 ? "成人" : "未成年";

具体的には以下のような分岐です。

タイマーの動作中でない
┣workモード:"作業開始"
┗workモードでは無い:"休憩開始"
タイマーの動作中
┣workモード:"作業中"
┗workモードでは無い:"休憩中"

  // 表示用ラベル
  const label = !isRunning
    ? mode === "work"
      ? "作業開始"
      : "休憩開始"
    : mode === "work"
    ? "作業中"
    : "休憩中";

最後に

機能追加するとしたら、そのセットの作業内容の目標とその履歴が見れると便利そうです。

皆さんもポモドーロテクニックで効率向上を目指しましょう!

Discussion

ログインするとコメントできます