About
RemoteScout24tasked me to develop a digital product that not only provided tangible value for their remote worker audience but also served as a strategic tool for attracting and retaining website users. In essence, this project was an example of "engineering as marketing." I responded by designing and developing a fully functional Pomodoro timer. Below, I outline the detailed process I followed.
Objective
Enhance productivity for remote workers by providing a simple, customizable Pomodoro timer that integrates work/break cycles with ambient playlists and audible notifications.
Empathize
I began by building a deep understanding of RemoteScout24's target audience and their pain points. To do this, I conducted six in-depth qualitative interviews with professionals. These conversations provided valuable insights into their unique challenges, needs, and work habits, which I then synthesized into a detailed user personas.
Rita Remote
Demographics
- Age: 32
- Role: Digital Marketer at a growing startup
- Location: Works from a home office with a structured daily routine
Goals
- Reduce distractions and prevent burnout
- Establish and maintain a structured workday
- Clearly delineate work time from personal time
- Optimize productivity without sacrificing well-being
Pain Points
- Time Management: Often loses track of time during focused work sessions.
- Work-Life Balance: Struggles with defining boundaries between work and personal time.
- Focus & Transition: Needs subtle cues to disengage from work and take rejuvenating breaks.
Behavior
- Relies on scheduled routines to keep focus
- Regularly uses productivity tools and planning apps
- Prefers clear signals and boundaries between work sessions and breaks
Felix Freelancer
Background
- Age: 28
- Role: Freelance graphic designer
- Work Environment: Works from diverse locations—cafés, coworking spaces, and home offices
Objectives
- Balance multiple projects and tasks throughout an unpredictable day
- Maintain productivity while nurturing creativity
- Adapt work sessions to fit his fluctuating schedule
Behaviors
- Frequently switches between creative tasks and administrative work
- Values flexibility and customization in his work tools
- Often improvises his schedule to suit project demands
Pain Points
- Irregular Routines: The absence of a fixed schedule can lead to either overworking or prolonged distractions.
- Task Overload: Difficulty prioritizing tasks without a structured approach.
- Boundary Setting: Struggles to differentiate between work time and personal time due to a fluid work environment.
Nadia Digital Nomad
Background
- Age: 26
- Role: Freelance writer and content creator
- Work Environment: Continuously on the move, working from various international locations
Objectives
- Maintain productivity amidst constant change
- Establish a semblance of routine in varied environments
- Balance the excitement of travel with work commitments
Behaviors
- Works from diverse, often unpredictable locations (e.g., cafés, hostels, coworking hubs)
- Enjoys the flexibility of her schedule but sometimes struggles with discipline
- Uses mobile apps to keep her organized while traveling
Pain Points
-
Distractions: New environments can be stimulating, making it hard to concentrate consistently.
-
Inconsistent Routine: The lack of a fixed location leads to irregular work patterns.
-
Work Prioritization: Finds it challenging to prioritize work over leisure in travel-heavy schedules.
Potential Benefit from Pomotime: While Nadia’s primary need isn’t explicitly for a Pomodoro timer, using Pomotime can help her create structured work sessions even on the road, making it easier to establish and maintain a productive routine regardless of her location.
Prioritization Rationale
After exploring these three personas, I prioritized Rita Remote as the primary user. This decision was driven by both business strategy and the specific paint points of this persona. This persona became a guiding framework for all subsequent design and development decisions. Here’s why Rita emerged as the primary target:
Alignment with RemoteScout24’s Core Audience
Although RemoteScout24 also caters to digital nomads and freelancers, the main user base consists of remote professionals. Rita’s profile aligns seamlessly with this demographic, ensuring that any solution developed directly addresses the needs of RemoteScout24’s primary audience.
Frequency and Intensity of Pain Points
Rita experiences challenges, such as poor time management, blurred boundaries between work and personal life, and difficulty transitioning between tasks that occur regularly and have a significant impact on her daily productivity. In contrast, while freelancers and digital nomads also face productivity issues, their challenges tend to be more situational and less consistent. Addressing Rita’s persistent problems promises a greater overall impact. This also gives RemoteScout24 the chance to communicate more frequently with its users and thus have more opportunities to collect data in order to improve the product.
Define
Building on the insights gathered during the Empathize phase, the next step was to clearly define the problem and envision a solution that would effectively address the needs of RemoteScout24’s primary audience. The key challenge identified was that remote professionals, exemplified by our persona Rita Remote, often struggle with time management, maintaining focus, and establishing clear work-life boundaries.
Problem Statement
Remote professionals need a simple yet effective way to manage their work sessions and breaks, ensuring they remain focused while also preventing burnout. The solution must be flexible, customizable, and engaging enough to integrate seamlessly into their daily routines.
Ideate
To tackle this challenge, I facilitated a small brainstorming session that explored a variety of digital product ideas aimed at enhancing productivity. Some of the concepts considered included:
- Integrated Task Management Tools: Systems that combined task lists with time tracking to help users organize their day.
- Ambient Sound Applications: Tools designed to provide focus-enhancing soundscapes tailored to different work environments.
- Focus-Enhancing Widgets: Simple applications that could offer timers, reminders, or gamified productivity challenges.
After evaluating these ideas against the frequency and intensity of the pain points identified, the concept of a Pomodoro timer quickly emerged as a compelling solution. The Pomodoro Technique, with its proven method of alternating focused work sessions and restorative breaks, directly aligned with the needs of our target audience. Its simplicity, ease of use, and ability to be customized made it the ideal choice.
Scope
Based on these insights, the product was defined as a fully functional, customizable Pomodoro timer designed specifically for remote workers. V1 should include:
- Customization. The timer should be flexible and allow users to set personalized work and break durations, directly addressing the diverse needs and schedules of remote workers.
- User Engagement. Integrating ambient playlists and audible notifications not only creates a soothing work environment, but also conditions users to associate specific sound cues with break times, much like a school bell signals recess. This effectively prompts user to take the necessary breaks.
Prototyping
The scope of v1 was pretty straightforward. I began by creating a clean, minimalistic wireframe in Balsamiq. The design featured a clear, intuitive layout: the logo was placed in the top left corner for brand visibility, while a settings button was positioned in the top right, allowing users to access customization options for music, work sessions, and break intervals.
Clicking the settings button opens a simple dialog box where users can quickly adjust timer preferences. This minimal settings interface ensures that users can personalize their experience without being overwhelmed by complexity.
I moved forward by designing a logo that encapsulated the brand’s identity in a simplistic yet meaningful way. I then curated a clean, cohesive color palette to establish a tranquil and productive atmosphere. For typography, I selected fonts that enhanced legibility, particularly opting for a monotype font for the timer counter to ensure the digits remained perfectly aligned and readable. Lastly, I defined a consistent style for the interface elements, ensuring that all components felt harmonized and intuitive while maintaining visual clarity.
Final Result
This is was the final result. You can test Pomotime here.
Development & Tech Stack
For this project, I implemented the solution using vanilla JavaScript, CSS, and HTML to maintain a lightweight and efficient codebase. Additionally, I integrated the open-source Phosphor Icons library to enhance the UI with high-quality, scalable icons and incorporated AudioPlayer.js to seamlessly handle audio playback—eliminating the need to develop a custom audio player from scratch.
Module Imports and Overall Structure
- Imports: The code begins by importing three functions from a statistics service module:
addAjaxStatistic
updateAjaxStatistic
trackUserPageView
- These functions are used later to log and update usage data, helping track user engagement and session details.
import {
addAjaxStatistic,
updateAjaxStatistic,
trackUserPageView
} from "../../../services/stats.service.js";
Main Function
The entire Pomodoro timer functionality is wrapped inside an asynchronous function named pomoTimer()
. This design encapsulates all timer-related logic and ensures that any asynchronous tasks (such as tracking page views) are handled properly.
async function pomoTimer(){...}
Timer Initialization with ProtoTimer
- ProtoTimer Constructor:
Inside
pomoTimer
, a constructor-like function named ProtoTimer is defined. When invoked, it returns an object containing all initial properties for the timer: - Timer Durations:
startTime
: Default starting value (1500 seconds = 25 minutes)pomodoroTime
: Work session duration (25 minutes)shortBreakTime
: Short break duration (5 minutes)longBreakTime
: Long break duration (15 minutes)
- Phase and State Management:
phase
: Indicates the current phase (initially set to "pomodoro")isPause
: Boolean to indicate if the timer is pausedintervalId
: Holds the identifier for the timer interval (for clearing later)pomodoroCount
: Tracks how many Pomodoro work sessions have been completedtimeSpent
: Cumulative time (in seconds) tracked across sessions (loaded from localStorage if available)
- Instantiation:
- A new timer object is created by calling
new ProtoTimer()
and is stored in the timer variable.
- A new timer object is created by calling
const ProtoTimer = function () {
let time = JSON.parse(localStorage.getItem("__rs_pomo_session_time"));
return {
startTime: 1500,
pomodoroTime: 1500,
shortBreakTime: 300,
longBreakTime: 900,
phase: "pomodoro",
isPause: false,
intervalId: undefined,
pomodoroCount: 0,
timeSpent: time === null ? 0 : time
}
}
const timer = new ProtoTimer();
DOM Elements and Event Listeners
-
DOM Selection: The code selects various DOM elements using
document.querySelector
for buttons, displays, and dialogs:- Timer controls: start, pause, stop, and skip buttons.
- Navigation buttons for selecting phases (pomodoro, short break, long break).
- Language switcher and settings elements.
- Display element to show the current time.
-
Event Listeners for UI Interaction:
- Global Click Listener: Closes the language switcher menu when a click happens outside the menu.
- Settings Dialog:
settingsBtn
triggers theopenSettings
function to reveal the settings dialog. settingsCloseBtn
and the background overlay (bgOverlay
) triggercloseSettings
to hide the dialog.
-
Phase Navigation: Clicking on navigation buttons (pomodoro, short break, long break) clears any existing timer interval, sets the new phase, updates the starting time, and refreshes the display.
-
Timer Controls:
startBtn
initiates the timer by calling thestart
function.pauseBtn
pauses the timer by invoking thepause
function.stopBtn
stops the timer completely via thestop
function.skipBtn
allows the user to move immediately to the next session phase by calling theskip
function.
const startBtn = document.querySelector("#start");
const pauseBtn = document.querySelector("#pause");
const stopBtn = document.querySelector("#stop");
const skipBtn = document.querySelector("#skip");
const timeDisplay = document.querySelector("#pomodoro-time-indicator");
const pomodoroBtn = document.querySelector("#pomodoro-btn");
const shortBreakBtn = document.querySelector("#short-break-btn");
const longBreakBtn = document.querySelector("#long-break-btn");
const langSwitcher = document.querySelector(".lang-switcher");
const langSwitcherMenu = document.querySelector(".lang-switcher-menu");
const settingsBtn = document.querySelector(".settings-btn");
const settingsDialog = document.querySelector(".settings-dialog");
const bgOverlay = document.querySelector(".bg-overlay");
const settingsCloseBtn = document.querySelector("#dialog-close-btn");
function openSettings() {
settingsDialog.classList.remove("hidden");
bgOverlay.classList.remove("hidden");
}
function closeSettings() {
settingsDialog.classList.add("hidden");
bgOverlay.classList.add("hidden");
}
function setBtnsTopStopState() {
startBtn.classList.remove("hidden");
pauseBtn.classList.add("hidden");
stopBtn.classList.add("hidden");
skipBtn.classList.add("hidden");
}
function setBtnsToPlayState() {
startBtn.classList.add("hidden");
pauseBtn.classList.remove("hidden");
stopBtn.classList.remove("hidden");
skipBtn.classList.remove("hidden");
}
function setBtnsToPauseState() {
startBtn.classList.remove("hidden");
pauseBtn.classList.add("hidden");
}
function formatToDisplayTime(time) {
// The largest round integer less than or equal to the result of time divided being by 60.
const minutes = Math.floor(time / 60);
// Seconds are the remainder of the time divided by 60 (modulus operator)
let seconds = time % 60;
// Round seconds to one decimal place
seconds = Math.round(seconds * 10) / 10;
// Get only the integer part of seconds
seconds = Math.floor(seconds);
// If the value of seconds is less than 10, then display seconds with a leading zero
if (seconds < 10) {
seconds = `0${seconds}`;
}
// The output in MM:SS format
return `${minutes}:${seconds}`;
}
function updatePomodoroCount() {
let pomodoroDisplay = document.querySelector("#pomodoros");
let text = timer.pomodoroCount === 1 ? "1 Pomodoro" : `${timer.pomodoroCount} Pomodoros`;
pomodoroDisplay.innerText = text;
}
function focusOnTimerNavBtn(btnId) {
unfocusNavBtns()
let btn = document.querySelector(btnId);
btn.classList.remove("timer--nav-btn__inactive");
btn.classList.add("timer--nav-btn__active");
}
function unfocusNavBtns() {
let timerNavBtns = document.querySelectorAll(".timer--nav-btn");
timerNavBtns.forEach(btn => {
btn.classList.add("timer--nav-btn__inactive");
btn.classList.remove("timer--nav-btn__active");
});
}
function start() {
if (timer.intervalId !== undefined) {
clearInterval(timer.intervalId);
}
if (timer.isPause === true) {
timer.isPause = false;
} else {
setCounterTime();
}
timer.intervalId = setInterval(() => {
timer.startTime = timer.startTime - 1;
updateTimeDisplay(timer.startTime);
timer.timeSpent = timer.timeSpent + 1;
handleSession(); // This function will be addressed later
if (timer.startTime < 0) {
clearInterval(timer.intervalId);
playRing();
setBtnsTopStopState();
setPhase();
}
}, 1000);
}
function pause() {
setBtnsToPauseState();
clearInterval(timer.intervalId);
}
function stop() {
setBtnsTopStopState();
clearInterval(timer.intervalId);
if (timer.phase === "shortBreak") {
timer.startTime = timer.shortBreakTime;
}
if (timer.phase === "longBreak") {
timer.startTime = timer.longBreakTime;
}
if (timer.phase === "pomodoro") {
timer.startTime = timer.pomodoroTime;
}
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function skip() {
clearInterval(timer.intervalId);
unfocusNavBtns();
if (timer.phase === "shortBreak") {
timer.pomodoroCount = timer.pomodoroCount + 1;
updatePomodoroCount();
}
if (timer.phase === "pomodoro") {
timer.phase = "shortBreak";
timer.startTime = timer.shortBreakTime;
focusOnTimerNavBtn("#short-break-btn");
} else if (timer.phase === "shortBreak" && (timer.pomodoroCount % 4) === 0) {
timer.phase = "longBreak";
timer.startTime = timer.longBreakTime;
focusOnTimerNavBtn("#long-break-btn");
} else {
timer.phase = "pomodoro";
timer.startTime = timer.pomodoroTime;
focusOnTimerNavBtn("#pomodoro-btn");
}
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
langSwitcher.addEventListener("click", function () {
langSwitcherMenu.classList.toggle("hidden");
if (langSwitcherMenu.classList.contains("hidden")) {
this.children[1].classList.remove("rotate-180");
} else {
this.children[1].classList.add("rotate-180");
}
})
pomodoroBtn.addEventListener("click", function () {
if (timer.phase !== "pomodoro") {
unfocusNavBtns()
focusOnTimerNavBtn("#pomodoro-btn");
clearInterval(timer.intervalId);
timer.phase = "pomodoro";
timer.startTime = timer.pomodoroTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
shortBreakBtn.addEventListener("click", function () {
if (timer.phase !== "shortBreak") {
unfocusNavBtns()
focusOnTimerNavBtn("#short-break-btn");
clearInterval(timer.intervalId);
timer.phase = "shortBreak";
timer.startTime = timer.shortBreakTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
longBreakBtn.addEventListener("click", function () {
if (timer.phase !== "longBreak") {
unfocusNavBtns()
focusOnTimerNavBtn("#long-break-btn");
clearInterval(timer.intervalId);
timer.phase = "longBreak";
timer.startTime = timer.longBreakTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
startBtn.addEventListener("click", function () {
setBtnsToPlayState();
start();
});
stopBtn.addEventListener("click", function () {
timer.isPause = false;
stop();
});
pauseBtn.addEventListener("click", function () {
pause();
timer.isPause = true;
});
skipBtn.addEventListener("click", function () {
setBtnsTopStopState();
skip();
});
Settings and Input Handling
-
Time Settings Update: The
updateTimeSetting
function takes a cached settings object (updated via user input) and:- Determines the current phase (pomodoro, short break, or long break).
- Updates the timer’s properties (
pomodoroTime
,shortBreakTime
,longBreakTime
) with the new values. - Sets
startTime
to the newly configured value for the active phase. - Updates the time display accordingly.
-
Input Validation: The
allowOnlyNumbers
function restricts input in settings fields to numbers, ensuring that users can only enter valid numeric values for the time durations. -
Handling Settings Dialog Events: The
handleTimeSettings
function attaches event listeners to input fields in the settings dialog:- Keypress: Validates input using
allowOnlyNumbers
. - Blur: Converts input (in minutes) to seconds and stores it in a temporary
cache
object. - Save: When the save button is clicked, the cached values are committed to the timer settings, and the settings dialog is closed.
- Keypress: Validates input using
function updateTimeSetting(cache) {
let phase = "";
if (timer.phase === "pomodoro") {
phase = "pomodoroTime";
}
if (timer.phase === "shortBreak") {
phase = "shortBreakTime";
}
if (timer.phase === "longBreak") {
phase = "longBreakTime";
}
timer.pomodoroTime = cache.pomodoroTime;
timer.shortBreakTime = cache.shortBreakTime;
timer.longBreakTime = cache.longBreakTime;
timer.startTime = timer[phase] = cache[phase];
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function handleTimeSettings() {
let pomodoroInput = document.querySelector("#pomodoro-time");
let shortBreakInput = document.querySelector("#short-break-time");
let longBreakInput = document.querySelector("#long-break-time");
let saveBtn = document.querySelector("#save-settings-btn");
let cache = new ProtoTimer();
pomodoroInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
shortBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
longBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
pomodoroInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.pomodoroTime = seconds;
});
shortBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.shortBreakTime = seconds;
});
longBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.longBreakTime = seconds;
});
saveBtn.addEventListener("click", function () {
updateTimeSetting(cache);
closeSettings();
});
}
Session Management and Analytics
- Session Identification: To track usage across sessions, the code uses several helper functions:
- UUID Generation: The
uuidv4
function creates a unique identifier. - Session Creation:
generateSessionId
creates a session object with a unique ID and an expiry time (set to midnight of the next day). - Local Storage: Functions like
setSessionId
,setSessionTime
,getSessionId
, anddiscardSession
manage session data inlocalStorage
.
- UUID Generation: The
- Analytics Updates: The
handleSession
function checks for a valid session and logs the session start or updates session time using the imported statistics functions (addAjaxStatistic
andupdateAjaxStatistic
). This ensures that usage data is recorded and updated periodically.
function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
function generateSessionId() {
const today = new Date();
const expiry = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 0, 0, 0, 0); // Set expiry time to midnight of the next day
let sessionId = uuidv4();
const sessionData = {
id: sessionId,
expiry: expiry.getTime()
};
return sessionData;
}
function setSessionId() {
localStorage.setItem("__rs_pomo_session_id", JSON.stringify(generateSessionId()));
}
function setSessionTime(timeInSeconds) {
localStorage.setItem("__rs_pomo_session_time", timeInSeconds);
}
function getSessionId() {
const sessionDataString = localStorage.getItem("__rs_pomo_session_id");
if (sessionDataString === null) {
return false;
}
const sessionData = JSON.parse(sessionDataString);
const expiryTime = sessionData.expiry;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
if (expiryTime > tomorrow.getTime()) {
discardSession();
return false;
}
return sessionData.id;
}
function discardSession() {
localStorage.removeItem("__rs_pomo_session_id");
localStorage.removeItem("__rs_pomo_session_time");
}
function handleSession() {
let isSessionId = getSessionId();
let date = new Date();
let [dateWithoutTime] = date.toISOString().split("T");
if (isSessionId === false) {
isSessionId = generateSessionId();
setSessionId(isSessionId);
addAjaxStatistic("Pomotime runtime", "Time/Session ID/Date", "0", isSessionId.id, dateWithoutTime);
} else {
setSessionTime(timer.timeSpent);
if (timer.timeSpent % 60 === 0) {
updateAjaxStatistic("Pomotime runtime", "Time/Session ID/Date", timer.timeSpent.toString(), isSessionId.id, dateWithoutTime);
}
}
}
Timer Navigation and Phase Switching
-
Navigation Buttons: Each navigation button (for pomodoro, short break, and long break) has an event listener that:
- Clears any existing timer interval.
- Sets the current phase.
- Updates the timer’s start time based on the chosen phase.
- Refreshes the UI (including highlighting the active button).
-
Phase Transition Logic: The
skip
function and the setPhase function manage transitions between phases:- Skipping: When the user clicks the skip button, the function clears the interval and advances the phase. For example, if currently in a pomodoro session, it switches to a short break. It also increments the
pomodoroCount
when transitioning from a break. - Automatic Transition: When the timer reaches zero, the
start
function’s interval callback triggers a phase change by callingsetPhase
, which:- Increments the Pomodoro count when appropriate.
- Determines whether to switch to a short break, long break (after every four Pomodoro sessions), or return to a work session.
- Updates the display and navigation button highlighting accordingly.
- Skipping: When the user clicks the skip button, the function clears the interval and advances the phase. For example, if currently in a pomodoro session, it switches to a short break. It also increments the
function setPhase() {
if (timer.phase === "shortBreak") {
timer.pomodoroCount = timer.pomodoroCount + 1;
updatePomodoroCount();
}
if (timer.phase === "pomodoro") {
timer.phase = "shortBreak";
updateTimeDisplay(timer.shortBreakTime);
focusOnTimerNavBtn("#short-break-btn");
} else if (timer.phase === "shortBreak" && (timer.pomodoroCount % 4) === 0) {
timer.phase = "longBreak";
updateTimeDisplay(timer.longBreakTime);
focusOnTimerNavBtn("#long-break-btn");
} else {
timer.phase = "pomodoro";
updateTimeDisplay(timer.pomodoroTime);
focusOnTimerNavBtn("#pomodoro-btn");
}
}
function start() {
if (timer.intervalId !== undefined) {
clearInterval(timer.intervalId);
}
if (timer.isPause === true) {
timer.isPause = false;
} else {
setCounterTime();
}
timer.intervalId = setInterval(() => {
timer.startTime = timer.startTime - 1;
updateTimeDisplay(timer.startTime);
timer.timeSpent = timer.timeSpent + 1;
handleSession();
if (timer.startTime < 0) {
clearInterval(timer.intervalId);
playRing();
setBtnsTopStopState();
setPhase();
}
}, 1000);
}
Core Timer Functions
-
Start Function: The
start
function initializes the timer:- It clears any existing intervals.
- Checks if the timer was paused; if not, it resets the counter for the current phase.
- Sets a new interval that:
- Decrements
timer.startTime
every second. - Updates the display using
updateTimeDisplay
. - Increments the cumulative
timeSpent
for analytics. - Calls
handleSession
to update session information. - Once the timer counts down past zero, it stops the interval, plays an alert sound (via
playRing
), resets button states, and callssetPhase
to transition to the next session.
- Decrements
-
Pause and Stop Functions:
- Pause: The
pause
function clears the interval and updates the button state to allow the user to resume later. - Stop: The
stop
function clears the interval, resets the timer’s start time based on the current phase, and updates the display accordingly.
- Pause: The
-
Display Formatting: The helper function
formatToDisplayTime
converts the timer’s seconds into a formatted string (MM:SS), ensuring that seconds are always displayed with two digits. -
Audio Notification: The
playRing
function creates an audio object, sets its source to a pre-defined ringtone, and plays it. This sound serves as an audible cue to signal the end of a session, much like a school bell indicates break time.
function start() {
if (timer.intervalId !== undefined) {
clearInterval(timer.intervalId);
}
if (timer.isPause === true) {
timer.isPause = false;
} else {
setCounterTime();
}
timer.intervalId = setInterval(() => {
timer.startTime = timer.startTime - 1;
updateTimeDisplay(timer.startTime);
timer.timeSpent = timer.timeSpent + 1;
handleSession();
if (timer.startTime < 0) {
clearInterval(timer.intervalId);
playRing();
setBtnsTopStopState();
setPhase();
}
}, 1000);
}
function formatToDisplayTime(time) {
// The largest round integer less than or equal to the result of time divided being by 60.
const minutes = Math.floor(time / 60);
// Seconds are the remainder of the time divided by 60 (modulus operator)
let seconds = time % 60;
// Round seconds to one decimal place
seconds = Math.round(seconds * 10) / 10;
// Get only the integer part of seconds
seconds = Math.floor(seconds);
// If the value of seconds is less than 10, then display seconds with a leading zero
if (seconds < 10) {
seconds = `0${seconds}`;
}
// The output in MM:SS format
return `${minutes}:${seconds}`;
}
function playRing() {
let audio = new Audio();
audio.src = ringTone;
audio.play();
}
Final Initialization and Page Tracking
- Page View Tracking: Before finalizing the setup, the code awaits the
trackUserPageView
function to log the page view analytics, ensuring that usage data is recorded from the moment the user lands on the page. - Settings and UI Initialization: The
handleTimeSettings
function is called to set up the settings dialog event handlers, and theinit
function sets the initial timer display based on the current phase.
function handleTimeSettings() {
let pomodoroInput = document.querySelector("#pomodoro-time");
let shortBreakInput = document.querySelector("#short-break-time");
let longBreakInput = document.querySelector("#long-break-time");
let saveBtn = document.querySelector("#save-settings-btn");
let cache = new ProtoTimer();
pomodoroInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
shortBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
longBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
pomodoroInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.pomodoroTime = seconds;
});
shortBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.shortBreakTime = seconds;
});
longBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.longBreakTime = seconds;
});
saveBtn.addEventListener("click", function () {
updateTimeSetting(cache);
closeSettings();
});
}
//Sets initial time display
function init() {
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
Full Code
import {
addAjaxStatistic,
updateAjaxStatistic,
trackUserPageView
} from "../../../services/stats.service.js";
export async function pomoTimer() {
const ProtoTimer = function () {
let time = JSON.parse(localStorage.getItem("__rs_pomo_session_time"));
return {
startTime: 1500,
pomodoroTime: 1500,
shortBreakTime: 300,
longBreakTime: 900,
phase: "pomodoro",
isPause: false,
intervalId: undefined,
pomodoroCount: 0,
timeSpent: time === null ? 0 : time
}
}
const timer = new ProtoTimer();
const currentURL = window.location.href;
const startBtn = document.querySelector("#start");
const pauseBtn = document.querySelector("#pause");
const stopBtn = document.querySelector("#stop");
const skipBtn = document.querySelector("#skip");
const timeDisplay = document.querySelector("#pomodoro-time-indicator");
const pomodoroBtn = document.querySelector("#pomodoro-btn");
const shortBreakBtn = document.querySelector("#short-break-btn");
const longBreakBtn = document.querySelector("#long-break-btn");
const langSwitcher = document.querySelector(".lang-switcher");
const langSwitcherMenu = document.querySelector(".lang-switcher-menu");
const settingsBtn = document.querySelector(".settings-btn");
const settingsDialog = document.querySelector(".settings-dialog");
const bgOverlay = document.querySelector(".bg-overlay");
const settingsCloseBtn = document.querySelector("#dialog-close-btn");
window.addEventListener("click", function (event) {
if (!langSwitcher.contains(event.target)) {
langSwitcherMenu.classList.add("hidden");
langSwitcher.children[1].classList.remove("rotate-180");
}
});
settingsBtn.addEventListener("click", openSettings);
settingsCloseBtn.addEventListener("click", closeSettings);
bgOverlay.addEventListener("click", closeSettings);
function openSettings() {
settingsDialog.classList.remove("hidden");
bgOverlay.classList.remove("hidden");
}
function closeSettings() {
settingsDialog.classList.add("hidden");
bgOverlay.classList.add("hidden");
}
function updateTimeSetting(cache) {
let phase = "";
if (timer.phase === "pomodoro") {
phase = "pomodoroTime";
}
if (timer.phase === "shortBreak") {
phase = "shortBreakTime";
}
if (timer.phase === "longBreak") {
phase = "longBreakTime";
}
timer.pomodoroTime = cache.pomodoroTime;
timer.shortBreakTime = cache.shortBreakTime;
timer.longBreakTime = cache.longBreakTime;
timer.startTime = timer[phase] = cache[phase];
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function allowOnlyNumbers(event) {
const keyCode = event.keyCode || event.which;
const keyValue = String.fromCharCode(keyCode);
// Use regex to test if the key pressed is a number
const isNumber = /^[0-9]*$/.test(keyValue);
// Allow only number keys, backspace, and delete, excluding `^`, `,`, `.`, and `
if (!isNumber && event.which !== 8 && event.which !== 46 && event.which !== 44 && event.which !== 94 && event.which !== 96) {
event.preventDefault();
return false;
}
return true;
}
function handleTimeSettings() {
let pomodoroInput = document.querySelector("#pomodoro-time");
let shortBreakInput = document.querySelector("#short-break-time");
let longBreakInput = document.querySelector("#long-break-time");
let saveBtn = document.querySelector("#save-settings-btn");
let cache = new ProtoTimer();
pomodoroInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
shortBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
longBreakInput.addEventListener("keypress", function (event) {
allowOnlyNumbers(event);
});
pomodoroInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.pomodoroTime = seconds;
});
shortBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.shortBreakTime = seconds;
});
longBreakInput.addEventListener("blur", function (event) {
let minutes = +event.target.value;
let seconds = minutes * 60;
cache.longBreakTime = seconds;
});
saveBtn.addEventListener("click", function () {
updateTimeSetting(cache);
closeSettings();
});
}
//Creates UUIDs
function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
function generateSessionId() {
const today = new Date();
const expiry = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 0, 0, 0, 0); // Set expiry time to midnight of the next day
let sessionId = uuidv4();
const sessionData = {
id: sessionId,
expiry: expiry.getTime()
};
return sessionData;
}
function setSessionId() {
localStorage.setItem("__rs_pomo_session_id", JSON.stringify(generateSessionId()));
}
function setSessionTime(timeInSeconds) {
localStorage.setItem("__rs_pomo_session_time", timeInSeconds);
}
function getSessionId() {
const sessionDataString = localStorage.getItem("__rs_pomo_session_id");
if (sessionDataString === null) {
return false;
}
const sessionData = JSON.parse(sessionDataString);
const expiryTime = sessionData.expiry;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
if (expiryTime > tomorrow.getTime()) {
discardSession();
return false;
}
return sessionData.id;
}
function discardSession() {
localStorage.removeItem("__rs_pomo_session_id");
localStorage.removeItem("__rs_pomo_session_time");
}
//Session variable should be valid for 1 day
//Random session var as identificator == sessionId
function handleSession() {
let isSessionId = getSessionId();
let date = new Date();
let [dateWithoutTime] = date.toISOString().split("T");
if (isSessionId === false) {
isSessionId = generateSessionId();
setSessionId(isSessionId);
addAjaxStatistic("Pomotime runtime", "Time/Session ID/Date", "0", isSessionId.id, dateWithoutTime);
} else {
setSessionTime(timer.timeSpent);
if (timer.timeSpent % 60 === 0) {
updateAjaxStatistic("Pomotime runtime", "Time/Session ID/Date", timer.timeSpent.toString(), isSessionId.id, dateWithoutTime);
}
}
}
langSwitcher.addEventListener("click", function () {
langSwitcherMenu.classList.toggle("hidden");
if (langSwitcherMenu.classList.contains("hidden")) {
this.children[1].classList.remove("rotate-180");
} else {
this.children[1].classList.add("rotate-180");
}
})
pomodoroBtn.addEventListener("click", function () {
if (timer.phase !== "pomodoro") {
unfocusNavBtns()
focusOnTimerNavBtn("#pomodoro-btn");
clearInterval(timer.intervalId);
timer.phase = "pomodoro";
timer.startTime = timer.pomodoroTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
shortBreakBtn.addEventListener("click", function () {
if (timer.phase !== "shortBreak") {
unfocusNavBtns()
focusOnTimerNavBtn("#short-break-btn");
clearInterval(timer.intervalId);
timer.phase = "shortBreak";
timer.startTime = timer.shortBreakTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
longBreakBtn.addEventListener("click", function () {
if (timer.phase !== "longBreak") {
unfocusNavBtns()
focusOnTimerNavBtn("#long-break-btn");
clearInterval(timer.intervalId);
timer.phase = "longBreak";
timer.startTime = timer.longBreakTime;
setBtnsTopStopState();
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
});
startBtn.addEventListener("click", function () {
setBtnsToPlayState();
start();
});
stopBtn.addEventListener("click", function () {
timer.isPause = false;
stop();
});
pauseBtn.addEventListener("click", function () {
pause();
timer.isPause = true;
});
skipBtn.addEventListener("click", function () {
setBtnsTopStopState();
skip();
});
function skip() {
clearInterval(timer.intervalId);
unfocusNavBtns();
if (timer.phase === "shortBreak") {
timer.pomodoroCount = timer.pomodoroCount + 1;
updatePomodoroCount();
}
if (timer.phase === "pomodoro") {
timer.phase = "shortBreak";
timer.startTime = timer.shortBreakTime;
focusOnTimerNavBtn("#short-break-btn");
} else if (timer.phase === "shortBreak" && (timer.pomodoroCount % 4) === 0) {
timer.phase = "longBreak";
timer.startTime = timer.longBreakTime;
focusOnTimerNavBtn("#long-break-btn");
} else {
timer.phase = "pomodoro";
timer.startTime = timer.pomodoroTime;
focusOnTimerNavBtn("#pomodoro-btn");
}
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function updatePomodoroCount() {
let pomodoroDisplay = document.querySelector("#pomodoros");
let text = timer.pomodoroCount === 1 ? "1 Pomodoro" : `${timer.pomodoroCount} Pomodoros`;
pomodoroDisplay.innerText = text;
}
function focusOnTimerNavBtn(btnId) {
unfocusNavBtns()
let btn = document.querySelector(btnId);
btn.classList.remove("timer--nav-btn__inactive");
btn.classList.add("timer--nav-btn__active");
}
function unfocusNavBtns() {
let timerNavBtns = document.querySelectorAll(".timer--nav-btn");
timerNavBtns.forEach(btn => {
btn.classList.add("timer--nav-btn__inactive");
btn.classList.remove("timer--nav-btn__active");
});
}
function setBtnsTopStopState() {
startBtn.classList.remove("hidden");
pauseBtn.classList.add("hidden");
stopBtn.classList.add("hidden");
skipBtn.classList.add("hidden");
}
function setBtnsToPlayState() {
startBtn.classList.add("hidden");
pauseBtn.classList.remove("hidden");
stopBtn.classList.remove("hidden");
skipBtn.classList.remove("hidden");
}
function setBtnsToPauseState() {
startBtn.classList.remove("hidden");
pauseBtn.classList.add("hidden");
}
function pause() {
setBtnsToPauseState();
clearInterval(timer.intervalId);
}
function stop() {
setBtnsTopStopState();
clearInterval(timer.intervalId);
if (timer.phase === "shortBreak") {
timer.startTime = timer.shortBreakTime;
}
if (timer.phase === "longBreak") {
timer.startTime = timer.longBreakTime;
}
if (timer.phase === "pomodoro") {
timer.startTime = timer.pomodoroTime;
}
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function formatToDisplayTime(time) {
// The largest round integer less than or equal to the result of time divided being by 60.
const minutes = Math.floor(time / 60);
// Seconds are the remainder of the time divided by 60 (modulus operator)
let seconds = time % 60;
// Round seconds to one decimal place
seconds = Math.round(seconds * 10) / 10;
// Get only the integer part of seconds
seconds = Math.floor(seconds);
// If the value of seconds is less than 10, then display seconds with a leading zero
if (seconds < 10) {
seconds = `0${seconds}`;
}
// The output in MM:SS format
return `${minutes}:${seconds}`;
}
//Sets initial time display
function init() {
timeDisplay.innerText = formatToDisplayTime(timer.startTime);
}
function updateTimeDisplay(time) {
timeDisplay.innerText = formatToDisplayTime(time);
}
function setPhase() {
if (timer.phase === "shortBreak") {
timer.pomodoroCount = timer.pomodoroCount + 1;
updatePomodoroCount();
}
if (timer.phase === "pomodoro") {
timer.phase = "shortBreak";
updateTimeDisplay(timer.shortBreakTime);
focusOnTimerNavBtn("#short-break-btn");
} else if (timer.phase === "shortBreak" && (timer.pomodoroCount % 4) === 0) {
timer.phase = "longBreak";
updateTimeDisplay(timer.longBreakTime);
focusOnTimerNavBtn("#long-break-btn");
} else {
timer.phase = "pomodoro";
updateTimeDisplay(timer.pomodoroTime);
focusOnTimerNavBtn("#pomodoro-btn");
}
}
function setCounterTime() {
if (timer.phase === "pomodoro") {
timer.startTime = timer.pomodoroTime;
}
if (timer.phase === "shortBreak") {
timer.startTime = timer.shortBreakTime;
}
if (timer.phase === "longBreak") {
timer.startTime = timer.longBreakTime;
}
}
function playRing() {
let audio = new Audio();
audio.src = ringTone;
audio.play();
}
function start() {
if (timer.intervalId !== undefined) {
clearInterval(timer.intervalId);
}
if (timer.isPause === true) {
timer.isPause = false;
} else {
setCounterTime();
}
timer.intervalId = setInterval(() => {
timer.startTime = timer.startTime - 1;
updateTimeDisplay(timer.startTime);
timer.timeSpent = timer.timeSpent + 1;
handleSession();
if (timer.startTime < 0) {
clearInterval(timer.intervalId);
playRing();
setBtnsTopStopState();
setPhase();
}
}, 1000);
}
await trackUserPageView("Pomotime", currentURL);
handleTimeSettings();
init();
}