diff --git a/src/plays/pomodoro-timer/PomodoroTimer.tsx b/src/plays/pomodoro-timer/PomodoroTimer.tsx new file mode 100644 index 000000000..07aaa231f --- /dev/null +++ b/src/plays/pomodoro-timer/PomodoroTimer.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import TimerDisplay from './components/TimerDisplay'; +import TimerControls from './components/TimerControls'; +import SessionSelector from './components/SessionSelector'; + +import { usePomodoroTimer, SessionType } from './hooks/usePomodoroTimer'; + +import './styles.css'; + +function PomodoroTimer(): JSX.Element { + const { session, timeLeft, start, pause, reset, updateSessionTime, changeSession } = + usePomodoroTimer(); + + return ( +
+
+
+ {/* Title */} +
Pomodoro Timer
+ + {/* Session Selector */} + + + {/* Timer Display */} + + + {/* Controls */} + +
+
+
+ ); +} + +export default PomodoroTimer; diff --git a/src/plays/pomodoro-timer/Readme.md b/src/plays/pomodoro-timer/Readme.md new file mode 100644 index 000000000..6f43e0f2c --- /dev/null +++ b/src/plays/pomodoro-timer/Readme.md @@ -0,0 +1,76 @@ +# Pomodoro Timer + +A customizable Pomodoro Timer built with React and TypeScript that helps users improve productivity using the Pomodoro Technique. + +Users can set focus, short break, and long break durations using an interactive scroll-based time picker. The timer supports start, pause, and reset controls, and automatically manages session timing. + +This play demonstrates clean React architecture, custom hooks, state management, and interactive UI design. + +--- + +## Play Demographic + +- Language: TypeScript +- Level: Intermediate + +--- + +## Creator Information + +- User: deansereigns +- GitHub: https://github.com/deansereigns + +--- + +## Features + +- Focus, Short Break, and Long Break sessions +- Scroll-based time selection (interactive wheel picker) +- Start, Pause, and Reset controls +- Automatic session handling +- Clean and responsive UI +- Built using React functional components and hooks + +--- + +## React Concepts Used + +- Functional Components +- useState for managing timer state +- useEffect for timer interval handling +- Custom Hook (usePomodoroTimer) +- Component composition +- Controlled components +- TypeScript for type safety + +--- + +## Implementation Details + +The timer logic is implemented using a custom hook (`usePomodoroTimer`) which manages: + +- session state +- timer countdown +- start, pause, reset logic +- time updates from scroll picker + +The UI is broken into modular components: + +- TimerDisplay +- DualTimePicker +- SessionSelector +- TimerControls + +This ensures clean separation of logic and presentation. + +--- + +## Considerations + +- Timer updates immediately when time is changed +- Scroll picker wraps values circularly +- Timer stops correctly when paused or reset +- Clean and reusable component structure + +--- + diff --git a/src/plays/pomodoro-timer/components/DualTimePicker.tsx b/src/plays/pomodoro-timer/components/DualTimePicker.tsx new file mode 100644 index 000000000..5891944b9 --- /dev/null +++ b/src/plays/pomodoro-timer/components/DualTimePicker.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +interface Props { + minutes: number; + seconds: number; + onChange: (minutes: number, seconds: number) => void; +} + +const DualTimePicker: React.FC = ({ minutes, seconds, onChange }) => { + const wrap = (value: number, max: number) => { + if (value < 0) return max; + if (value > max) return 0; + + return value; + }; + + const changeMinutes = (delta: number) => { + const newMinutes = wrap(minutes + delta, 59); + onChange(newMinutes, seconds); + }; + + const changeSeconds = (delta: number) => { + const newSeconds = wrap(seconds + delta, 59); + onChange(minutes, newSeconds); + }; + + const Wheel = ({ value, onChangeFn }: { value: number; onChangeFn: (delta: number) => void }) => { + const prev = wrap(value - 1, 59); + const next = wrap(value + 1, 59); + + return ( +
+
onChangeFn(-1)}> + {String(prev).padStart(2, '0')} +
+ +
{String(value).padStart(2, '0')}
+ +
onChangeFn(1)}> + {String(next).padStart(2, '0')} +
+
+ ); + }; + + return ( +
+ + +
:
+ + +
+ ); +}; + +export default DualTimePicker; diff --git a/src/plays/pomodoro-timer/components/SessionSelector.tsx b/src/plays/pomodoro-timer/components/SessionSelector.tsx new file mode 100644 index 000000000..45d34c292 --- /dev/null +++ b/src/plays/pomodoro-timer/components/SessionSelector.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { SessionType } from '../hooks/usePomodoroTimer'; + +interface Props { + current: SessionType; + onChange: (session: SessionType) => void; +} + +const SessionSelector: React.FC = ({ current, onChange }) => { + const sessions: SessionType[] = ['focus', 'shortBreak', 'longBreak']; + + const labels: Record = { + focus: 'Focus', + shortBreak: 'Short Break', + longBreak: 'Long Break' + }; + + return ( +
+ {sessions.map((session) => ( + + ))} +
+ ); +}; + +export default SessionSelector; diff --git a/src/plays/pomodoro-timer/components/TimerControls.tsx b/src/plays/pomodoro-timer/components/TimerControls.tsx new file mode 100644 index 000000000..3f77539d7 --- /dev/null +++ b/src/plays/pomodoro-timer/components/TimerControls.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface Props { + onStart: () => void; + onPause: () => void; + onReset: () => void; +} + +const TimerControls: React.FC = ({ onStart, onPause, onReset }) => { + return ( +
+ + + + + +
+ ); +}; + +export default TimerControls; diff --git a/src/plays/pomodoro-timer/components/TimerDisplay.tsx b/src/plays/pomodoro-timer/components/TimerDisplay.tsx new file mode 100644 index 000000000..51f058c6c --- /dev/null +++ b/src/plays/pomodoro-timer/components/TimerDisplay.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import DualTimePicker from './DualTimePicker'; + +interface Props { + session: string; + timeLeft: number; + onTimeChange: (seconds: number) => void; +} + +const TimerDisplay: React.FC = ({ session, timeLeft, onTimeChange }) => { + const [editing, setEditing] = useState(false); + + const containerRef = useRef(null); + + const minutes = Math.floor(timeLeft / 60); + + const seconds = timeLeft % 60; + + const formatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setEditing(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleChange = (m: number, s: number) => { + onTimeChange(m * 60 + s); + }; + + return ( +
+
{session.toUpperCase()}
+ + {!editing && ( +
setEditing(true)}> + {formatted} +
+ )} + + {editing && } +
+ ); +}; + +export default TimerDisplay; diff --git a/src/plays/pomodoro-timer/hooks/usePomodoroTimer.ts b/src/plays/pomodoro-timer/hooks/usePomodoroTimer.ts new file mode 100644 index 000000000..17efe888d --- /dev/null +++ b/src/plays/pomodoro-timer/hooks/usePomodoroTimer.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from 'react'; + +export type SessionType = 'focus' | 'shortBreak' | 'longBreak'; + +const DEFAULT_TIMES = { + focus: 25 * 60, + shortBreak: 5 * 60, + longBreak: 15 * 60 +}; + +export const usePomodoroTimer = () => { + const [session, setSession] = useState('focus'); + + const [timeLeft, setTimeLeft] = useState(DEFAULT_TIMES.focus); + + const [isRunning, setIsRunning] = useState(false); + + const intervalRef = useRef(null); + + /* Timer logic */ + useEffect(() => { + if (!isRunning) return; + + intervalRef.current = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + switchSession(); + + return 0; + } + + return prev - 1; + }); + }, 1000); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isRunning]); + + /* Switch session automatically */ + const switchSession = () => { + if (session === 'focus') { + changeSession('shortBreak'); + } else { + changeSession('focus'); + } + }; + + /* Manual session change */ + const changeSession = (newSession: SessionType) => { + setSession(newSession); + setTimeLeft(DEFAULT_TIMES[newSession]); + setIsRunning(false); + }; + + /* Start */ + const start = () => { + setIsRunning(true); + }; + + /* Pause */ + const pause = () => { + setIsRunning(false); + }; + + /* Reset */ + const reset = () => { + setIsRunning(false); + setTimeLeft(DEFAULT_TIMES[session]); + }; + + /* Update time from scroll wheel */ + const updateSessionTime = (seconds: number) => { + setTimeLeft(seconds); + }; + + return { + session, + timeLeft, + start, + pause, + reset, + updateSessionTime, + changeSession + }; +}; diff --git a/src/plays/pomodoro-timer/styles.css b/src/plays/pomodoro-timer/styles.css new file mode 100644 index 000000000..06d995220 --- /dev/null +++ b/src/plays/pomodoro-timer/styles.css @@ -0,0 +1,142 @@ +/* Main container */ +.play-details-body { + display: flex; + justify-content: center; + align-items: center; + min-height: 70vh; +} + +/* Card */ +.pomodoro-card { + background: #0f172a; + padding: 30px; + border-radius: 16px; + width: 340px; + text-align: center; + color: white; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); +} + +/* Title */ +.pomodoro-title { + font-size: 26px; + font-weight: bold; + margin-bottom: 20px; +} + +/* Session selector */ +.session-selector { + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +.session-btn { + flex: 1; + margin: 4px; + padding: 8px; + border-radius: 8px; + border: none; + background: #1e293b; + color: #94a3b8; + cursor: pointer; + transition: 0.2s; +} + +.session-btn.active { + background: #3b82f6; + color: white; +} + +/* Session label */ +.session-label { + font-size: 14px; + margin-bottom: 10px; + color: #94a3b8; +} + +/* Timer display */ +.timer-display { + margin: 20px 0; +} + +/* Big time text */ +.timer-time.big { + font-size: 52px; + font-weight: bold; + cursor: pointer; +} + +/* Wheel container */ +.time-wheel-container { + display: flex; + justify-content: center; + margin-top: 10px; +} + +/* Dual wheel */ +.dual-wheel { + display: flex; + gap: 10px; +} + +/* Wheel column */ +.wheel-column { + display: flex; + flex-direction: column; + align-items: center; +} + +/* Wheel item */ +.wheel-item { + font-size: 28px; + padding: 6px 0; + transition: 0.2s; +} + +/* Center active item */ +.wheel-item.active { + font-size: 42px; + font-weight: bold; + color: white; +} + +/* Top/bottom faded items */ +.wheel-item.faded { + font-size: 20px; + opacity: 0.35; + cursor: pointer; +} + +/* Controls */ +.timer-controls { + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +/* Buttons */ +.control-btn { + flex: 1; + margin: 5px; + padding: 10px; + border-radius: 8px; + border: none; + cursor: pointer; + font-weight: bold; +} + +.control-btn.start { + background: #22c55e; + color: white; +} + +.control-btn.pause { + background: #f59e0b; + color: white; +} + +.control-btn.reset { + background: #ef4444; + color: white; +}