Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/plays/pomodoro-timer/PomodoroTimer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="play-details">
<div className="play-details-body">
<div className="pomodoro-card">
{/* Title */}
<div className="pomodoro-title">Pomodoro Timer</div>

{/* Session Selector */}
<SessionSelector current={session} onChange={changeSession} />

{/* Timer Display */}
<TimerDisplay session={session} timeLeft={timeLeft} onTimeChange={updateSessionTime} />

{/* Controls */}
<TimerControls onPause={pause} onReset={reset} onStart={start} />
</div>
</div>
</div>
);
}

export default PomodoroTimer;
76 changes: 76 additions & 0 deletions src/plays/pomodoro-timer/Readme.md
Original file line number Diff line number Diff line change
@@ -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

---

57 changes: 57 additions & 0 deletions src/plays/pomodoro-timer/components/DualTimePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';

interface Props {
minutes: number;
seconds: number;
onChange: (minutes: number, seconds: number) => void;
}

const DualTimePicker: React.FC<Props> = ({ 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 (
<div className="wheel">
<div className="wheel-item faded" onClick={() => onChangeFn(-1)}>
{String(prev).padStart(2, '0')}
</div>

<div className="wheel-item active">{String(value).padStart(2, '0')}</div>

<div className="wheel-item faded" onClick={() => onChangeFn(1)}>
{String(next).padStart(2, '0')}
</div>
</div>
);
};

return (
<div className="dual-picker">
<Wheel value={minutes} onChangeFn={changeMinutes} />

<div className="separator">:</div>

<Wheel value={seconds} onChangeFn={changeSeconds} />
</div>
);
};

export default DualTimePicker;
33 changes: 33 additions & 0 deletions src/plays/pomodoro-timer/components/SessionSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ current, onChange }) => {
const sessions: SessionType[] = ['focus', 'shortBreak', 'longBreak'];

const labels: Record<SessionType, string> = {
focus: 'Focus',
shortBreak: 'Short Break',
longBreak: 'Long Break'
};

return (
<div className="session-selector">
{sessions.map((session) => (
<button
className={current === session ? 'session-btn active' : 'session-btn'}
key={session}
onClick={() => onChange(session)}
>
{labels[session]}
</button>
))}
</div>
);
};

export default SessionSelector;
27 changes: 27 additions & 0 deletions src/plays/pomodoro-timer/components/TimerControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';

interface Props {
onStart: () => void;
onPause: () => void;
onReset: () => void;
}

const TimerControls: React.FC<Props> = ({ onStart, onPause, onReset }) => {
return (
<div className="timer-controls">
<button className="control-btn start" onClick={onStart}>
Start
</button>

<button className="control-btn pause" onClick={onPause}>
Pause
</button>

<button className="control-btn reset" onClick={onReset}>
Reset
</button>
</div>
);
};

export default TimerControls;
53 changes: 53 additions & 0 deletions src/plays/pomodoro-timer/components/TimerDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ session, timeLeft, onTimeChange }) => {
const [editing, setEditing] = useState(false);

const containerRef = useRef<HTMLDivElement>(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 (
<div className="timer-display" ref={containerRef}>
<div className="session-label">{session.toUpperCase()}</div>

{!editing && (
<div className="timer-time" onClick={() => setEditing(true)}>
{formatted}
</div>
)}

{editing && <DualTimePicker minutes={minutes} seconds={seconds} onChange={handleChange} />}
</div>
);
};

export default TimerDisplay;
87 changes: 87 additions & 0 deletions src/plays/pomodoro-timer/hooks/usePomodoroTimer.ts
Original file line number Diff line number Diff line change
@@ -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<SessionType>('focus');

const [timeLeft, setTimeLeft] = useState<number>(DEFAULT_TIMES.focus);

const [isRunning, setIsRunning] = useState<boolean>(false);

const intervalRef = useRef<NodeJS.Timeout | null>(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
};
};
Loading
Loading