Skip to content

Commit 647089a

Browse files
authored
feat(plays): add pomodoro timer play (#1680)
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.
1 parent ab2bfbe commit 647089a

8 files changed

Lines changed: 511 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
3+
import TimerDisplay from './components/TimerDisplay';
4+
import TimerControls from './components/TimerControls';
5+
import SessionSelector from './components/SessionSelector';
6+
7+
import { usePomodoroTimer, SessionType } from './hooks/usePomodoroTimer';
8+
9+
import './styles.css';
10+
11+
function PomodoroTimer(): JSX.Element {
12+
const { session, timeLeft, start, pause, reset, updateSessionTime, changeSession } =
13+
usePomodoroTimer();
14+
15+
return (
16+
<div className="play-details">
17+
<div className="play-details-body">
18+
<div className="pomodoro-card">
19+
{/* Title */}
20+
<div className="pomodoro-title">Pomodoro Timer</div>
21+
22+
{/* Session Selector */}
23+
<SessionSelector current={session} onChange={changeSession} />
24+
25+
{/* Timer Display */}
26+
<TimerDisplay session={session} timeLeft={timeLeft} onTimeChange={updateSessionTime} />
27+
28+
{/* Controls */}
29+
<TimerControls onPause={pause} onReset={reset} onStart={start} />
30+
</div>
31+
</div>
32+
</div>
33+
);
34+
}
35+
36+
export default PomodoroTimer;

src/plays/pomodoro-timer/Readme.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Pomodoro Timer
2+
3+
A customizable Pomodoro Timer built with React and TypeScript that helps users improve productivity using the Pomodoro Technique.
4+
5+
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.
6+
7+
This play demonstrates clean React architecture, custom hooks, state management, and interactive UI design.
8+
9+
---
10+
11+
## Play Demographic
12+
13+
- Language: TypeScript
14+
- Level: Intermediate
15+
16+
---
17+
18+
## Creator Information
19+
20+
- User: deansereigns
21+
- GitHub: https://github.com/deansereigns
22+
23+
---
24+
25+
## Features
26+
27+
- Focus, Short Break, and Long Break sessions
28+
- Scroll-based time selection (interactive wheel picker)
29+
- Start, Pause, and Reset controls
30+
- Automatic session handling
31+
- Clean and responsive UI
32+
- Built using React functional components and hooks
33+
34+
---
35+
36+
## React Concepts Used
37+
38+
- Functional Components
39+
- useState for managing timer state
40+
- useEffect for timer interval handling
41+
- Custom Hook (usePomodoroTimer)
42+
- Component composition
43+
- Controlled components
44+
- TypeScript for type safety
45+
46+
---
47+
48+
## Implementation Details
49+
50+
The timer logic is implemented using a custom hook (`usePomodoroTimer`) which manages:
51+
52+
- session state
53+
- timer countdown
54+
- start, pause, reset logic
55+
- time updates from scroll picker
56+
57+
The UI is broken into modular components:
58+
59+
- TimerDisplay
60+
- DualTimePicker
61+
- SessionSelector
62+
- TimerControls
63+
64+
This ensures clean separation of logic and presentation.
65+
66+
---
67+
68+
## Considerations
69+
70+
- Timer updates immediately when time is changed
71+
- Scroll picker wraps values circularly
72+
- Timer stops correctly when paused or reset
73+
- Clean and reusable component structure
74+
75+
---
76+
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react';
2+
3+
interface Props {
4+
minutes: number;
5+
seconds: number;
6+
onChange: (minutes: number, seconds: number) => void;
7+
}
8+
9+
const DualTimePicker: React.FC<Props> = ({ minutes, seconds, onChange }) => {
10+
const wrap = (value: number, max: number) => {
11+
if (value < 0) return max;
12+
if (value > max) return 0;
13+
14+
return value;
15+
};
16+
17+
const changeMinutes = (delta: number) => {
18+
const newMinutes = wrap(minutes + delta, 59);
19+
onChange(newMinutes, seconds);
20+
};
21+
22+
const changeSeconds = (delta: number) => {
23+
const newSeconds = wrap(seconds + delta, 59);
24+
onChange(minutes, newSeconds);
25+
};
26+
27+
const Wheel = ({ value, onChangeFn }: { value: number; onChangeFn: (delta: number) => void }) => {
28+
const prev = wrap(value - 1, 59);
29+
const next = wrap(value + 1, 59);
30+
31+
return (
32+
<div className="wheel">
33+
<div className="wheel-item faded" onClick={() => onChangeFn(-1)}>
34+
{String(prev).padStart(2, '0')}
35+
</div>
36+
37+
<div className="wheel-item active">{String(value).padStart(2, '0')}</div>
38+
39+
<div className="wheel-item faded" onClick={() => onChangeFn(1)}>
40+
{String(next).padStart(2, '0')}
41+
</div>
42+
</div>
43+
);
44+
};
45+
46+
return (
47+
<div className="dual-picker">
48+
<Wheel value={minutes} onChangeFn={changeMinutes} />
49+
50+
<div className="separator">:</div>
51+
52+
<Wheel value={seconds} onChangeFn={changeSeconds} />
53+
</div>
54+
);
55+
};
56+
57+
export default DualTimePicker;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import { SessionType } from '../hooks/usePomodoroTimer';
3+
4+
interface Props {
5+
current: SessionType;
6+
onChange: (session: SessionType) => void;
7+
}
8+
9+
const SessionSelector: React.FC<Props> = ({ current, onChange }) => {
10+
const sessions: SessionType[] = ['focus', 'shortBreak', 'longBreak'];
11+
12+
const labels: Record<SessionType, string> = {
13+
focus: 'Focus',
14+
shortBreak: 'Short Break',
15+
longBreak: 'Long Break'
16+
};
17+
18+
return (
19+
<div className="session-selector">
20+
{sessions.map((session) => (
21+
<button
22+
className={current === session ? 'session-btn active' : 'session-btn'}
23+
key={session}
24+
onClick={() => onChange(session)}
25+
>
26+
{labels[session]}
27+
</button>
28+
))}
29+
</div>
30+
);
31+
};
32+
33+
export default SessionSelector;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
3+
interface Props {
4+
onStart: () => void;
5+
onPause: () => void;
6+
onReset: () => void;
7+
}
8+
9+
const TimerControls: React.FC<Props> = ({ onStart, onPause, onReset }) => {
10+
return (
11+
<div className="timer-controls">
12+
<button className="control-btn start" onClick={onStart}>
13+
Start
14+
</button>
15+
16+
<button className="control-btn pause" onClick={onPause}>
17+
Pause
18+
</button>
19+
20+
<button className="control-btn reset" onClick={onReset}>
21+
Reset
22+
</button>
23+
</div>
24+
);
25+
};
26+
27+
export default TimerControls;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
3+
import DualTimePicker from './DualTimePicker';
4+
5+
interface Props {
6+
session: string;
7+
timeLeft: number;
8+
onTimeChange: (seconds: number) => void;
9+
}
10+
11+
const TimerDisplay: React.FC<Props> = ({ session, timeLeft, onTimeChange }) => {
12+
const [editing, setEditing] = useState(false);
13+
14+
const containerRef = useRef<HTMLDivElement>(null);
15+
16+
const minutes = Math.floor(timeLeft / 60);
17+
18+
const seconds = timeLeft % 60;
19+
20+
const formatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
21+
22+
useEffect(() => {
23+
const handleClickOutside = (event: MouseEvent) => {
24+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
25+
setEditing(false);
26+
}
27+
};
28+
29+
document.addEventListener('mousedown', handleClickOutside);
30+
31+
return () => document.removeEventListener('mousedown', handleClickOutside);
32+
}, []);
33+
34+
const handleChange = (m: number, s: number) => {
35+
onTimeChange(m * 60 + s);
36+
};
37+
38+
return (
39+
<div className="timer-display" ref={containerRef}>
40+
<div className="session-label">{session.toUpperCase()}</div>
41+
42+
{!editing && (
43+
<div className="timer-time" onClick={() => setEditing(true)}>
44+
{formatted}
45+
</div>
46+
)}
47+
48+
{editing && <DualTimePicker minutes={minutes} seconds={seconds} onChange={handleChange} />}
49+
</div>
50+
);
51+
};
52+
53+
export default TimerDisplay;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
export type SessionType = 'focus' | 'shortBreak' | 'longBreak';
4+
5+
const DEFAULT_TIMES = {
6+
focus: 25 * 60,
7+
shortBreak: 5 * 60,
8+
longBreak: 15 * 60
9+
};
10+
11+
export const usePomodoroTimer = () => {
12+
const [session, setSession] = useState<SessionType>('focus');
13+
14+
const [timeLeft, setTimeLeft] = useState<number>(DEFAULT_TIMES.focus);
15+
16+
const [isRunning, setIsRunning] = useState<boolean>(false);
17+
18+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
19+
20+
/* Timer logic */
21+
useEffect(() => {
22+
if (!isRunning) return;
23+
24+
intervalRef.current = setInterval(() => {
25+
setTimeLeft((prev) => {
26+
if (prev <= 1) {
27+
switchSession();
28+
29+
return 0;
30+
}
31+
32+
return prev - 1;
33+
});
34+
}, 1000);
35+
36+
return () => {
37+
if (intervalRef.current) clearInterval(intervalRef.current);
38+
};
39+
}, [isRunning]);
40+
41+
/* Switch session automatically */
42+
const switchSession = () => {
43+
if (session === 'focus') {
44+
changeSession('shortBreak');
45+
} else {
46+
changeSession('focus');
47+
}
48+
};
49+
50+
/* Manual session change */
51+
const changeSession = (newSession: SessionType) => {
52+
setSession(newSession);
53+
setTimeLeft(DEFAULT_TIMES[newSession]);
54+
setIsRunning(false);
55+
};
56+
57+
/* Start */
58+
const start = () => {
59+
setIsRunning(true);
60+
};
61+
62+
/* Pause */
63+
const pause = () => {
64+
setIsRunning(false);
65+
};
66+
67+
/* Reset */
68+
const reset = () => {
69+
setIsRunning(false);
70+
setTimeLeft(DEFAULT_TIMES[session]);
71+
};
72+
73+
/* Update time from scroll wheel */
74+
const updateSessionTime = (seconds: number) => {
75+
setTimeLeft(seconds);
76+
};
77+
78+
return {
79+
session,
80+
timeLeft,
81+
start,
82+
pause,
83+
reset,
84+
updateSessionTime,
85+
changeSession
86+
};
87+
};

0 commit comments

Comments
 (0)