From d0eb8bc935dfd801225ba8fe3bf34ae67613ceee Mon Sep 17 00:00:00 2001 From: deansereigns Date: Sun, 15 Feb 2026 23:53:45 +0530 Subject: [PATCH] feat(plays): add pomodoro timer play create a pomodoro timer with focus, short break, and long break sessions. add scroll-based time selection, timer controls, and automatic session handling using react functional components and hooks. --- src/plays/pomodoro-timer/PomodoroTimer.tsx | 36 +++++ src/plays/pomodoro-timer/Readme.md | 27 ++++ .../components/DualTimePicker.tsx | 57 +++++++ .../components/SessionSelector.tsx | 33 ++++ .../components/TimerControls.tsx | 27 ++++ .../components/TimerDisplay.tsx | 53 +++++++ .../pomodoro-timer/hooks/usePomodoroTimer.ts | 87 +++++++++++ src/plays/pomodoro-timer/styles.css | 142 ++++++++++++++++++ 8 files changed, 462 insertions(+) create mode 100644 src/plays/pomodoro-timer/PomodoroTimer.tsx create mode 100644 src/plays/pomodoro-timer/Readme.md create mode 100644 src/plays/pomodoro-timer/components/DualTimePicker.tsx create mode 100644 src/plays/pomodoro-timer/components/SessionSelector.tsx create mode 100644 src/plays/pomodoro-timer/components/TimerControls.tsx create mode 100644 src/plays/pomodoro-timer/components/TimerDisplay.tsx create mode 100644 src/plays/pomodoro-timer/hooks/usePomodoroTimer.ts create mode 100644 src/plays/pomodoro-timer/styles.css 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..845ababe0 --- /dev/null +++ b/src/plays/pomodoro-timer/Readme.md @@ -0,0 +1,27 @@ +# Pomodoro Timer + +A customizable Pomodoro Timer built with React and TypeScript that allows users to set focus and break durations. It includes start, pause, and reset controls and automatically switches between sessions. This play demonstrates React hooks, state management, timer logic, and modular component design. + +## Play Demographic + +- Language: ts +- Level: Intermediate + +## Creator Information + +- User: deansereigns +- Gihub Link: https://github.com/deansereigns +- Blog: +- Video: + +## Implementation Details + +Update your implementation idea and details here + +## Consideration + +Update all considerations(if any) + +## Resources + +Update external resources(if any) 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; +}