S SmartDocs
시리즈: React.js javascript 269 줄 · 업데이트 2026-04-02

Project_PomodoroTimer.jsx

React.js/lessons/10_useEffect/projects/Project_PomodoroTimer.jsx

/**
 * Project: 番茄鐘計時器 — useEffect 練習
 *
 * 學習重點:
 * - useEffect 搭配 setInterval(計時器)
 * - 清除函式(clearInterval)
 * - 依賴陣列的正確使用
 * - 修改 document.title(副作用)
 * - useEffect 中的條件邏輯
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState, useEffect } from 'react';

function PomodoroTimer() {
  const [mode, setMode] = useState('work'); // 'work' | 'shortBreak' | 'longBreak'
  const [timeLeft, setTimeLeft] = useState(25 * 60);
  const [isRunning, setIsRunning] = useState(false);
  const [completedPomodoros, setCompletedPomodoros] = useState(0);
  const [sessions, setSessions] = useState([]);

  const durations = {
    work: 25 * 60,
    shortBreak: 5 * 60,
    longBreak: 15 * 60,
  };

  const modeConfig = {
    work: { label: '專注', color: '#ef4444', bg: '#fef2f2', icon: '🍅' },
    shortBreak: { label: '短休息', color: '#22c55e', bg: '#f0fdf4', icon: '☕' },
    longBreak: { label: '長休息', color: '#3b82f6', bg: '#eff6ff', icon: '🌴' },
  };

  // 主要計時器 Effect
  useEffect(() => {
    if (!isRunning) return;

    const intervalId = setInterval(() => {
      setTimeLeft((prev) => {
        if (prev <= 1) {
          setIsRunning(false);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(intervalId);
  }, [isRunning]);

  // 時間到的 Effect
  useEffect(() => {
    if (timeLeft === 0 && !isRunning) {
      if (mode === 'work') {
        const newCount = completedPomodoros + 1;
        setCompletedPomodoros(newCount);
        setSessions((prev) => [
          { time: new Date().toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }), type: 'work' },
          ...prev,
        ]);

        // 每 4 個番茄鐘後長休息
        if (newCount % 4 === 0) {
          switchMode('longBreak');
        } else {
          switchMode('shortBreak');
        }
      } else {
        switchMode('work');
      }

      // 通知
      try {
        if (Notification.permission === 'granted') {
          new Notification(`${modeConfig[mode].label}結束!`);
        }
      } catch {}
    }
  }, [timeLeft, isRunning]);

  // 更新頁面標題
  useEffect(() => {
    const config = modeConfig[mode];
    if (isRunning) {
      document.title = `${formatTime(timeLeft)} - ${config.label} | 番茄鐘`;
    } else {
      document.title = '番茄鐘計時器';
    }
    return () => {
      document.title = '番茄鐘計時器';
    };
  }, [timeLeft, isRunning, mode]);

  // 鍵盤快捷鍵
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.code === 'Space') {
        e.preventDefault();
        setIsRunning((prev) => !prev);
      }
      if (e.code === 'KeyR') {
        resetTimer();
      }
    };
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [mode]);

  const switchMode = (newMode) => {
    setMode(newMode);
    setTimeLeft(durations[newMode]);
    setIsRunning(false);
  };

  const resetTimer = () => {
    setTimeLeft(durations[mode]);
    setIsRunning(false);
  };

  const formatTime = (seconds) => {
    const m = Math.floor(seconds / 60);
    const s = seconds % 60;
    return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
  };

  const progress = 1 - timeLeft / durations[mode];
  const config = modeConfig[mode];

  const circumference = 2 * Math.PI * 140;
  const strokeDashoffset = circumference * (1 - progress);

  return (
    <div style={{
      minHeight: '100vh',
      backgroundColor: config.bg,
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      fontFamily: "'Segoe UI', sans-serif",
      padding: '20px',
      transition: 'background-color 0.5s',
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '8px', fontSize: '1.8rem' }}>
        {config.icon} 番茄鐘
      </h1>
      <p style={{ color: '#94a3b8', margin: '0 0 32px' }}>
        已完成 {completedPomodoros} 個番茄鐘
      </p>

      {/* 模式切換 */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '32px', backgroundColor: 'rgba(255,255,255,0.8)', padding: '4px', borderRadius: '12px' }}>
        {Object.entries(modeConfig).map(([key, cfg]) => (
          <button
            key={key}
            onClick={() => switchMode(key)}
            style={{
              padding: '8px 20px', borderRadius: '10px', border: 'none',
              backgroundColor: mode === key ? cfg.color : 'transparent',
              color: mode === key ? 'white' : '#64748b',
              cursor: 'pointer', fontWeight: mode === key ? 'bold' : 'normal',
              fontSize: '0.9rem', transition: 'all 0.2s',
            }}
          >
            {cfg.icon} {cfg.label}
          </button>
        ))}
      </div>

      {/* 圓形計時器 */}
      <div style={{ position: 'relative', width: '300px', height: '300px', marginBottom: '32px' }}>
        <svg width="300" height="300" viewBox="0 0 300 300" style={{ transform: 'rotate(-90deg)' }}>
          <circle cx="150" cy="150" r="140" fill="none" stroke="#e2e8f0" strokeWidth="8" />
          <circle
            cx="150" cy="150" r="140"
            fill="none"
            stroke={config.color}
            strokeWidth="8"
            strokeLinecap="round"
            strokeDasharray={circumference}
            strokeDashoffset={strokeDashoffset}
            style={{ transition: 'stroke-dashoffset 0.5s linear' }}
          />
        </svg>
        <div style={{
          position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
          textAlign: 'center',
        }}>
          <div style={{ fontSize: '4rem', fontWeight: 'bold', color: '#0f172a', fontFamily: 'monospace', letterSpacing: '4px' }}>
            {formatTime(timeLeft)}
          </div>
          <div style={{ color: config.color, fontWeight: '600', fontSize: '1rem', marginTop: '4px' }}>
            {config.label}
          </div>
        </div>
      </div>

      {/* 控制按鈕 */}
      <div style={{ display: 'flex', gap: '12px', marginBottom: '32px' }}>
        <button
          onClick={() => setIsRunning(!isRunning)}
          style={{
            padding: '14px 40px', borderRadius: '14px', border: 'none',
            backgroundColor: isRunning ? '#64748b' : config.color,
            color: 'white', cursor: 'pointer', fontWeight: 'bold',
            fontSize: '1.1rem', minWidth: '140px', transition: 'background-color 0.2s',
          }}
        >
          {isRunning ? '⏸ 暫停' : '▶ 開始'}
        </button>
        <button
          onClick={resetTimer}
          style={{
            padding: '14px 24px', borderRadius: '14px',
            border: '2px solid #e2e8f0', backgroundColor: 'white',
            color: '#64748b', cursor: 'pointer', fontSize: '1rem',
          }}
        >
          ↻ 重置
        </button>
      </div>

      {/* 番茄鐘進度 */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
        {[1, 2, 3, 4].map((i) => (
          <div
            key={i}
            style={{
              width: '24px', height: '24px', borderRadius: '50%',
              backgroundColor: completedPomodoros % 4 >= i ? config.color : '#e2e8f0',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              fontSize: '0.7rem', color: 'white', transition: 'background-color 0.3s',
            }}
          >
            {completedPomodoros % 4 >= i ? '✓' : i}
          </div>
        ))}
        <span style={{ color: '#94a3b8', fontSize: '0.8rem', marginLeft: '4px' }}>
          → 長休息
        </span>
      </div>

      {/* 歷史記錄 */}
      {sessions.length > 0 && (
        <div style={{
          backgroundColor: 'rgba(255,255,255,0.8)', borderRadius: '12px', padding: '16px',
          width: '100%', maxWidth: '300px',
        }}>
          <h3 style={{ margin: '0 0 8px', fontSize: '0.9rem', color: '#475569' }}>
            今日記錄
          </h3>
          {sessions.slice(0, 8).map((s, i) => (
            <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: '0.8rem', color: '#64748b' }}>
              <span>🍅 完成一個番茄鐘</span>
              <span>{s.time}</span>
            </div>
          ))}
        </div>
      )}

      <p style={{ color: '#cbd5e1', fontSize: '0.75rem', marginTop: '24px' }}>
        快捷鍵:Space 開始/暫停 · R 重置
      </p>
    </div>
  );
}

export default PomodoroTimer;

관련 글