Learn Svelte: Creating a Pomodoro Timer
Jaime González García
Posted on January 13, 2020
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:
- Picking the most important task,
- Starting the pomodoro timer, and...
- 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>
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>
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:
- A button to kick off the pomodoro
- 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>
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>
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>
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>
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)}`;
}
Which uses a couple of helpers:
const secondsToMinutes = (seconds) => Math.floor(seconds / 60);
const padWithZeroes = (number) => number.toString().padStart(2, '0');
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>
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>
And looks like this:
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);
}
And TaDa! we have a working timer:
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:
- The pomodoro count of the current task should increase by one
- 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);
}
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
andSHORT_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);
}
}
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);
}
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);
}
}
The rest
function sets the timer into the resting state:
function rest(time){
pomodoroTime = time;
interval = setInterval(() => {
if (pomodoroTime === 0) {
idle();
}
pomodoroTime -= 1;
},1000);
}
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;
}
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>
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>
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();
}
And now we can start and cancel pomodoros:
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'};
And we'll store the current state of the pomodoro timer in a currentState
variable:
let currentState = State.idle;
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;
}
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>
Awesome!
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>
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>
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;
}
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;
}
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);
}
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);
}
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;
}
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;
}
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>
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;
}
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:
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).
Posted on January 13, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024
November 26, 2024