Pomotime

A simple Pomodoro Timer.

Design
Frontend Engineering
pomotime

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.

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

Brainstorm Session

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.

Wireframe

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.

Wireframe

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.

Styling Elements

Final Result

This is was the final result. You can test Pomotime here. Current Website

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.

Tech stack

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 paused
    • intervalId: Holds the identifier for the timer interval (for clearing later)
    • pomodoroCount: Tracks how many Pomodoro work sessions have been completed
    • timeSpent: 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.
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 the openSettings function to reveal the settings dialog.
    • settingsCloseBtn and the background overlay (bgOverlay) trigger closeSettings 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 the start function.
    • pauseBtn pauses the timer by invoking the pause function.
    • stopBtn stops the timer completely via the stop function.
    • skipBtn allows the user to move immediately to the next session phase by calling the skip 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.
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, and discardSession manage session data in localStorage.
  • 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 and updateAjaxStatistic). 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 calling setPhase, 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.
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 calls setPhase to transition to the next session.
  • 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.
  • 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 the init 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();
}