DEV Community

Cover image for Learn Svelte: Creating a Pomodoro Timer
Jaime González García
Jaime González García

Posted on • Edited on • Originally published at barbarianmeetscoding.com

Learn Svelte: Creating a Pomodoro Timer

This article was originally published on Barbarian Meets Coding.

Svelte is a modern web framework that takes a novel approach to building web applications by moving the bulk of its work from runtime to compile-time. Being a compiler-first framework allows Svelte to do some very interesting stuff that is unavailable to other frameworks like disappearing from your application at runtime, or allowing for a component centered development with HTML, JavaScript and CSS coexisting within the same Svelte file in a very web standards friendly fashion.

In this series we'll follow along as I use Svelte for the first time to build an app. I'll use my go-to project[^1] to learn new frameworks: A Pomodoro Technique app, which is a little bit more involved than a TODO list in that it has at least a couple of components (a timer and a list of tasks) that need to interact with each other.

In this part four of the series we continue coding along as we create a pomodoro timer that will allow us to work on a given task with our complete focus and full attention. Let's get started!

Haven't read the other articles in this series? Then you may want to take a look at this list of resources for getting started with Svelte, and the first and second parts of building the Pomodoro Technique app.

Working on a Task with Your Full Attention

In the last part of this series we learned how when using the Pomodoro Technique you'll typically start your day sitting down, deciding what you'll achieve during the day and breaking it in as many pomodoros as you think it'll take. A pomodoro is a special unit of time used in The Pomodoro Technique which represents 25 minutes of uninterrupted work focusing on a single task.

The next step in The Pomodoro Technique consists in:

  1. Picking the most important task,
  2. Starting the pomodoro timer, and...
  3. Start kicking ass by focusing single-mindedly on that task for the next 25 minutes.

After the 25 minutes have passed, you'll rest for 5 minutes, and then start a new pomodoro. After 4 pomodoros have been completed you'll rest for 20 minutes. It is important that both when the pomodoro starts and when it finishes, we get a auditory cue which will act as trigger to first get us into focus, and then to get us into a resting mindset.

So if we were to attemp to build a pomodoro timer to support this workflow, it would need to fullfill the following set of requirements:

  • It should have three states: An active state where we are working on a task and a state where we're resting and an idle state where we're not doing anything at all.
  • In the active state it should count from 25 minutes downwards
    • When a pomodoro starts we should hear a cue
    • When a pomodoro ends we should hear another cue
    • We should be able to cancel or stop a pomodoro any time
  • In the resting state the timer should count from 5 or 20 minutes downwards
    • It should count from 20 minutes downwards when 4 pomodoros have been completed
    • It should count from 5 minutes downwards any other time
  • In the idle state nothing happens

Once a pomodoro has been completed whe should increase the number of pomodoros invested in the task in progress, and whenever a pomodoro is cancelled we need to type down the reason why (how were we interrupted? Why couldn't we keep our focus?). In this part of the series we'll just focus on building the timer itself, and in future articles we'll continue improving the timer and finally putting everything together. Let's get to it!

The Pomodoro Timer

Since a pomodoro timer seems like a completely separate responsibility from anything else in our app up to this point it deserves its own component. So I'll start by creating a new component called PomodoroTimer.svelte:

<p>
  Hi, I'm a pomodoro timer. Yo!
</p>
Enter fullscreen mode Exit fullscreen mode

And adding it to our App.svelte component:

<script>
    let title = "il Pomodoro";
  import TaskList from './TaskList.svelte';
  import PomodoroTimer from './PomodoroTimer.svelte';
</script>

<main>
  <h1>{title}</h1>
  <PomodoroTimer />
  <TaskList />
</main>
Enter fullscreen mode Exit fullscreen mode

I remember the rookie mistake I made in earlier parts of the series and I import the component before I use it in my template. Now my dev environment should display the new component...

Although it doesn't...

Weird...

Recheck, look at typos, refresh, rerun dev server. After some troubleshooting I realize that I need to do a hard refresh in my browser, it seems like it is caching localhost:5000. So hard refresh it is and now I see the new component. Sweet!

Starting a Pomodoro

Let's begin by implementing a way to start working on our first pomodoro. We're going to need:

  1. A button to kick off the pomodoro
  2. A way to represent the time left in a pomodoro

The button is quite simple. We update our svelte component template to include a new button that when clicked will start a new pomodoro:

<section>
  <p>
    Hi, I'm a pomodoro timer. Yo!
  </p>
  <button on:click={startPomodoro}>start</button>
</section>
Enter fullscreen mode Exit fullscreen mode

Since we don't have a pomodoro timer just yet, we'll start by creating an empty startPomodoro function for the time being:

<script>
  function startPomodoro(){}
</script>
Enter fullscreen mode Exit fullscreen mode

Now we need a way to represent the pomodoro timer. The initial state of the timer will be the length of a pomodoro (25 minutes). And since we'll often interact with the timer by decreasing a second at a time, we'll represent the length of a pomodoro in seconds (instead of minutes):

<script>
  // length of a pomodoro in seconds
  const POMODORO_S = 25 * 60;

  // time left in the current pomodoro
  let pomodoroTime = POMODORO_S;

  function startPomodoro(){}
</script>
Enter fullscreen mode Exit fullscreen mode

Since I don't like having magic numbers in my code I'll extract the time conversion between minutes and seconds inside a function:

<script>
  const minutesToSeconds = (minutes) => minutes * 60;

  // length of a pomodoro in seconds
  const POMODORO_S = minutesToSeconds(25);

  // time left in the current pomodoro
  let pomodoroTime = POMODORO_S;

  function startPomodoro(){}
</script>
Enter fullscreen mode Exit fullscreen mode

Now we need to represent that time in the template in the format MM:SS. We can use a function to transform the pomodoroTime into the desired format:

  function formatTime(timeInSeconds) { 
    const minutes = secondsToMinutes(timeInSeconds);
    const remainingSeconds = timeInSeconds % 60;
    return `${padWithZeroes(minutes)}:${padWithZeroes(remainingSeconds)}`;
  }
Enter fullscreen mode Exit fullscreen mode

Which uses a couple of helpers:

  const secondsToMinutes = (seconds) => Math.floor(seconds / 60);
  const padWithZeroes = (number) => number.toString().padStart(2, '0');
Enter fullscreen mode Exit fullscreen mode

Having defined formatTime we can use it in our template to transform the value of pomodoroTime:

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro}>start</button>
  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

The complete component now looks like this:

<script>
  const minutesToSeconds = (minutes) => minutes * 60;
  const secondsToMinutes = (seconds) => Math.floor(seconds / 60);
  const padWithZeroes = (number) => number.toString().padStart(2, '0');

  // length of a pomodoro in seconds
  const POMODORO_S = minutesToSeconds(25);

  // time left in the current pomodoro
  let pomodoroTime = POMODORO_S;

  function formatTime(timeInSeconds) { 
    const minutes = secondsToMinutes(timeInSeconds);
    const remainingSeconds = timeInSeconds % 60;
    return `${padWithZeroes(minutes)}:${padWithZeroes(remainingSeconds)}`;
  }

  function startPomodoro(){}
</script>

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro}>start</button>
  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

And looks like this:

A pomodoro app with a timer and a series of tasks

But if we click on the button start nothing happens. We still need to implement the startPomodro function. Now that we have an initial implementation for the timer we can fill in its implementation:

function startPomodoro() { 
  setInterval(() => {
    pomodoroTime -= 1;
  },1000);
}
Enter fullscreen mode Exit fullscreen mode

And TaDa! we have a working timer:

A pomodoro app with a timer and a series of tasks. The user clicks on start and the pomodoro timer starts its countdown.

Completing a Pomodoro and Taking a Break

Now there's two options, we can either focus on working on the task at hand and complete a pomodoro (Yihoo! Great job!) or we can cancel the pomodoro because we've been interrupted by something or someone.

When we complete a pomodoro, two things should happen:

  1. The pomodoro count of the current task should increase by one
  2. The timer goes into a resting state and starts counting down

Since we aren't going to integrate the timer with the rest of the app yet, let's focus on item number #2 by creating a new function completePomodoro. Whenever the pomodoroTime count down arrives to 0 we complete the pomodoro calling this new function:

function startPomodoro() { 
  setInterval(() => {
    if (pomodoroTime === 0) {
      completePomodoro();
    }
    pomodoroTime -= 1;
  },1000);
}
Enter fullscreen mode Exit fullscreen mode

Whenever we complete a pomodoro we're going to slide into a resting state counting down from 20 minutes or 5 minutes depending on whether we have completed 4 pomodoros up to this point. So:

  • We define a couple of constants to store the lenghts of the breaks LONG_BREAK_S and SHORT_BREAK_S
  • We define a completedPomodoros variable we'll use to keep track of how many pomodoros we have completed up to this point. This variable will determine whether we take the short or long break.
  • We implement the completePomodoro to complete a pomodoro and jump into the resting state:
  const LONG_BREAK_S = minutesToSeconds(20);
  const SHORT_BREAK_S = minutesToSeconds(5);
  let completedPomodoros = 0;

  function completePomodoro(){
    completedPomodoros++;
    if (completedPomodoros === 4) {
      rest(LONG_BREAK_S);
      completedPomodoros = 0;
    } else {
      rest(SHORT_BREAK_S);
    }
  }
Enter fullscreen mode Exit fullscreen mode

We still have an interval running our counting down function so we need to make sure to stop that interval before we proceed. We update the startPomodoro function to store a reference to the interval:

let interval;
function startPomodoro() { 
  interval = setInterval(() => {
    if (pomodoroTime === 0) {
      completePomodoro();
    }
    pomodoroTime -= 1;
  },1000);
}
Enter fullscreen mode Exit fullscreen mode

And clear it whenever we complete a pomodoro:

function completePomodoro(){
  clearInterval(interval):
  completedPomodoros++;
  // TODO: update the current task with a completed pomodoro
  if (completedPomodoros === 4) {
    rest(LONG_BREAK_S);
    completedPomodoros = 0;
  } else {
    rest(SHORT_BREAK_S);
  }
}
Enter fullscreen mode Exit fullscreen mode

The rest function sets the timer into the resting state:

function rest(time){
  pomodoroTime = time;
  interval = setInterval(() => {
    if (pomodoroTime === 0) {
      idle();
    }
    pomodoroTime -= 1;
  },1000);
}
Enter fullscreen mode Exit fullscreen mode

It's very similar to an in-progress pomodoro but it sets the pomodoro into an idle state when the count down finishes. The idle state can be modelled with this other function:

  function idle(){
    clearInterval(interval);
    pomodoroTime = POMODORO_S;
  }
Enter fullscreen mode Exit fullscreen mode

The whole component looks like this right now:

<script>
  const minutesToSeconds = (minutes) => minutes * 60;
  const secondsToMinutes = (seconds) => Math.floor(seconds / 60);
  const padWithZeroes = (number) => number.toString().padStart(2, '0');

  const POMODORO_S = minutesToSeconds(25);
  const LONG_BREAK_S = minutesToSeconds(20);
  const SHORT_BREAK_S = minutesToSeconds(5);

  let pomodoroTime = POMODORO_S;
  let completedPomodoros = 0;
  let interval;

  function formatTime(timeInSeconds) { 
    const minutes = secondsToMinutes(timeInSeconds);
    const remainingSeconds = timeInSeconds % 60;
    return `${padWithZeroes(minutes)}:${padWithZeroes(remainingSeconds)}`;
  }

  function startPomodoro() { 
    interval = setInterval(() => {
      if (pomodoroTime === 0) {
        completePomodoro();
      }
      pomodoroTime -= 1;
    },1000);
  }

  function completePomodoro(){
    clearInterval(interval);
    completedPomodoros++;
    // TODO: update the current task with a completed pomodoro
    if (completedPomodoros === 4) {
      rest(LONG_BREAK_S);
      completedPomodoros = 0;
    } else {
      rest(SHORT_BREAK_S);
    }
  }

  function rest(time){
    pomodoroTime = time;
    interval = setInterval(() => {
      if (pomodoroTime === 0) {
        idle();
      }
      pomodoroTime -= 1;
    },1000);
  }

  function idle(){
    clearInterval(interval);
    pomodoroTime = POMODORO_S;
  }
</script>

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro}>start</button>
  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

Now, when things go wrong and we get distracted we must cancel the pomodoro, write down the cause of our distraction (so we can reflect and learn from it) and start over. Let's update our timer to support this use case.

Cancelling a Pomodoro

In order to be able to cancel a pomodoro we'll add a new button to our template:

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro}>start</button>
    <!-- New button HERE -->
    <button on:click={cancelPomodoro}>cancel</button>
    <!-- END new stuff-->
  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

Whenever the user clicks on this button we'll cancel the current pomodoro using the cancelPomodoro function:

function cancelPomodoro(){
  // TODO: Add some logic to prompt the user to write down
  // the cause of the interruption.
  idle();
}
Enter fullscreen mode Exit fullscreen mode

And now we can start and cancel pomodoros:

A pomodoro app with a timer and a series of tasks. The user clicks on start and the pomodoro timer starts its countdown. Then they click on cancel and the pomodor stops.

Improving The User Experience Slightly

With our current implementation a user can start a pomodoro when a pomodoro has already started, and likewise cancel a pomodoro which hasn't started yet which makes no sense. Instead the user should get some visual cues as to what actions make sense under the different conditions. So we're going to improve the user experience of our timer by:

  • Enabling the start pomodoro button only when we're in an idle state
  • Enabling the cancel pomodoro button only when we're in a pomodoro-in-progress state

In order to do that we need to keep track of the state of the timer so we start by modeling the different states available with an object:

const State = {idle: 'idle', inProgress: 'in progress', resting: 'resting'};
Enter fullscreen mode Exit fullscreen mode

And we'll store the current state of the pomodoro timer in a currentState variable:

let currentState = State.idle;
Enter fullscreen mode Exit fullscreen mode

We then update the different lifecycle methods to update this state as needed:

function startPomodoro() { 
  currentState = State.inProgress;
  interval = setInterval(() => {
    if (pomodoroTime === 0) {
      completePomodoro();
    }
    pomodoroTime -= 1;
  },1000);
}

function rest(time){
  currentState = State.resting;
  pomodoroTime = time;
  interval = setInterval(() => {
    if (pomodoroTime === 0) {
      idle();
    }
    pomodoroTime -= 1;
  },1000);
}

function idle(){
  currentState = State.idle;
  clearInterval(interval);
  pomodoroTime = POMODORO_S;
}
Enter fullscreen mode Exit fullscreen mode

And now we update our templates to take advantage of this new knowledge to enable/disable the buttons that control the timer:

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro} disabled={currentState !== State.idle}>start</button>
    <button on:click={cancelPomodoro} disabled={currentState !== State.inProgress}>cancel</button>
  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

Awesome!

A pomodoro app with a timer and a series of tasks. The user clicks on start and the pomodoro timer starts its countdown. Then they click on cancel and the pomodor stops. The buttons are only enabled when it makes sense.

The full component at this point looks like this:

<script>
  const minutesToSeconds = (minutes) => minutes * 60;
  const secondsToMinutes = (seconds) => Math.floor(seconds / 60);
  const padWithZeroes = (number) => number.toString().padStart(2, '0');
  const State = {idle: 'idle', inProgress: 'in progress', resting: 'resting'};

  const POMODORO_S = minutesToSeconds(25);
  const LONG_BREAK_S = minutesToSeconds(20);
  const SHORT_BREAK_S = minutesToSeconds(5);

  let currentState = State.idle;
  let pomodoroTime = POMODORO_S;
  let completedPomodoros = 0;
  let interval;

  function formatTime(timeInSeconds) { 
    const minutes = secondsToMinutes(timeInSeconds);
    const remainingSeconds = timeInSeconds % 60;
    return `${padWithZeroes(minutes)}:${padWithZeroes(remainingSeconds)}`;
  }

  function startPomodoro() { 
    currentState = State.inProgress;
    interval = setInterval(() => {
      if (pomodoroTime === 0) {
        completePomodoro();
      }
      pomodoroTime -= 1;
    },1000);
  }

  function completePomodoro(){
    clearInterval(interval);
    completedPomodoros++;
    if (completedPomodoros === 4) {
      rest(LONG_BREAK_S);
      completedPomodoros = 0;
    } else {
      rest(SHORT_BREAK_S);
    }
  }

  function rest(time){
    currentState = State.resting;
    pomodoroTime = time;
    interval = setInterval(() => {
      if (pomodoroTime === 0) {
        idle();
      }
      pomodoroTime -= 1;
    },1000);
  }

  function cancelPomodoro() {
    // TODO: Add some logic to prompt the user to write down
    // the cause of the interruption.
    idle();
  }

  function idle(){
    currentState = State.idle;
    clearInterval(interval);
    pomodoroTime = POMODORO_S;
  }
</script>

<section>
  <p>
    {formatTime(pomodoroTime)}
  </p>
  <footer>
    <button on:click={startPomodoro} disabled={currentState !== State.idle}>start</button>
    <button on:click={cancelPomodoro} disabled={currentState !== State.inProgress}>cancel</button>
    <!--button on:click={completePomodoro}>complete</button-->

  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

Adding Some Styling

Now let's apply some styling to our timer. The timer consists on some text with the timer itself and a couple of buttons. The styles of the timer feel like something that should belong to this component and this component only, but the styles of the buttons sound like something that should be consistent across the whole application.

Styling the timer text is quite straighforward. We just update the styles within PomodoroTimer.svelte. While I'm doing this, I remember HTML has a time element that is a more semantic way to represent time in a web application and I switch my puny p element for time:

<style>
  time {
    display: block;
    font-size: 5em;
    font-weight: 300;
    margin-bottom: 0.2em;
  }
</style>

<section>
  <time>
    {formatTime(pomodoroTime)}
  </time>
  <footer>
    <button on:click={startPomodoro} disabled={currentState !== State.idle}>start</button>
    <button on:click={cancelPomodoro} disabled={currentState !== State.inProgress}>cancel</button>
    <!--button on:click={completePomodoro}>complete</button-->

  </footer>
</section>
Enter fullscreen mode Exit fullscreen mode

And now, for the buttons, how does one do application-wide styles in Svelte? There's different options but for this particular use case we can take advantage of the global.css file that is already available in our starter project. In fact, it already has some styles for buttons:

button {
  color: #333;
  background-color: #f4f4f4;
  outline: none;
}

button:disabled {
  color: #999;
}

button:not(:disabled):active {
  background-color: #ddd;
}

button:focus {
  border-color: #666;
}
Enter fullscreen mode Exit fullscreen mode

Let's tweak this a little. We're going to have a primary and secondary action buttons, where the primary action is going to be the start pomodoro, and the rest will be treated as secondary action (we really want to get our pomodoros started). The primary action will use a set of accent colors while the secondary action will use a set of base colors which we'll define as a color scheme using CSS variables:

:root{
 --black: #333;
 --base: white;
 --base-light: #f4f4f4;
 --base-dark: #ddd;

 --white: white;
 --accent: orangered;
 --accent-light: #ff4500d6;
 --accent-dark: #e83f00;
}
Enter fullscreen mode Exit fullscreen mode

Now we redefine the styles for the secondary action button which we'll just act as the default look and feel of a button:

button {
  background-color: var(--base);
  border-color: var(--black);
  color: var(--black);
  font-size: 1.5em;
  font-weight: inherit;
  outline: none;
  text-transform: uppercase;
  transition: background-color .2s, color .2s, border-color .2s, opacity .2s;
}

button:disabled {
  opacity: 0.5;
}

button:focus,
button:not(:disabled):hover {
  background-color: var(--base-light);
}

button:not(:disabled):active {
  background-color: var(--base-dark);
}
Enter fullscreen mode Exit fullscreen mode

And we define new styles for the primary action button which will build on top of the styles above:

button.primary {
  background-color: var(--accent);
  border-color: var(--accent);
  color: var(--white);
}

button.primary:not(:disabled):hover {
  background-color: var(--accent-light);
  border-color: var(--accent-light);
}

button.primary:not(:disabled):active {
  background-color: var(--accent-dark);
  border-color: var(--accent-dark);
}
Enter fullscreen mode Exit fullscreen mode

Now to make the inputs fit it with the buttons we'll tweak their font-size:

input, button, select, textarea {
  font-family: inherit;
  font-size: 1.5em;
  font-weight: inherit;
  padding: 0.4em;
  margin: 0 0 0.5em 0;
  box-sizing: border-box;
  border: 1px solid #ccc;
  border-radius: 2px;
}
Enter fullscreen mode Exit fullscreen mode

We also update the font-weight of our app to be lighter and more minismalistic because why not:

body {
  color: var(--black);
  margin: 0;
  padding: 8px;
  box-sizing: border-box;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
  font-weight: 300;
}
Enter fullscreen mode Exit fullscreen mode

We make the add task button in the TaskList.svelte component also be a primary button:

<button class="primary" on:click={addTask}>Add a new task</button>
Enter fullscreen mode Exit fullscreen mode

And why not? Let's make the title a little bit bigger (I'm getting carried away here). Inside App.svelte:

  h1 {
    color: var(--accent);
    text-transform: uppercase;
    font-size: 6em;
    margin: 0;
    font-weight: 100;
  }
Enter fullscreen mode Exit fullscreen mode

And that's it! We may need to revisit the styles to make sure the contrast is enough to support great accessibility but this is a start:

A pomodoro app with a timer and a series of tasks. The user clicks on start and the pomodoro timer starts its countdown. Then they click on cancel and the pomodor stops. The buttons are only enabled when it makes sense. The styles have been improved from previous iterations.

Sweet! And that's all for today. In the next part in the series we'll continue with:

  • Refactoring our timer with the help of automated tests (because I'm not super happy with the current implementation).
  • Adding auditory feedback when the pomodoro starts and ends.
  • Integrating the timer with the tasks so we have a full pomodoro technique flow.

Looking for the source code for the pomodoro app?

Look no more! You can find it on GitHub ready to be cloned and enjoyed, or on the Svelte REPL where you can tinker with it right away.

More Reflections

Working with Svelte continues to be very pleasant. In addition to my previous reflections (1, 2), I've found that:

  • Formatting functions are very straightforward. When I needed to format the time in a specific format in my template, I just went with my gut, wrapped the formatting within a vanilla JavaScript function, used it on the template formatTime(pomodoroTime)} and it worked.
  • Assigning and binding properties to a DOM element is also straightforward. Once more, I just went with my gut, typed disabled={currentState !== State.idle} and it worked as I expected it to. Principle of least surprise! Yey!
  • Having the styles within a component feels very natural and useful: There's no need to switch context as the styles are in close proximity to where they're used. If you ever need to update the styles of a component you know where to go, and likewise if you remove a component its styles disappear with it (You don't need to search around your application in a deadly csshunt).

Top comments (0)