Pomodoro Clock

A nice Pomodoro Clock with vue.js.

Project for FreeCodeCamp. Decided to give Vue.js a try and was pleasantly suprised how easy it was to create a highly reactive application.

Made with

Html
Css/SCSS
Javascript

Html

<script type="text/x-template" id="app-header">
  <header>
    <div class="container">
      <div class="nav">
        <div class="nav-logo">Pomodoro Clock</div>
        <button @click="toggleSidebar" class="nav-toggle">
          <svg width="22" height="8" xmlns="http://www.w3.org/2000/svg"><path d="M1 0h20a1 1 0 0 1 0 2H1a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H1a1 1 0 1 1 0-2z" fill="#FFF" fill-rule="evenodd"/></svg>
        </button>
        <app-sidebar
          v-show="isSidebarOpen"
          :init-work="initWork"
          :init-short-break="initShortBreak"
          :is-sidebar-open="isSidebarOpen"
          @change="filterChange"
          @toggle="toggleSidebar"
          @reset="$emit('reset')"
        ></app-sidebar>
      </div>
    </div>
  </header>
</script>

<script type="text/x-template" id="app-sidebar">
  <transition
    name="sidebar"
    enter-active-class="sidebar--show"
  >
    <div class="sidebar">
      <button @click="$emit('toggle')" class="sidebar-toggle">
        <svg width="14" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M5.586 7L.636 2.05A1 1 0 0 1 2.05.636L7 5.586l4.95-4.95a1 1 0 0 1 1.414 1.414L8.414 7l4.95 4.95a1 1 0 0 1-1.414 1.414L7 8.414l-4.95 4.95A1 1 0 0 1 .636 11.95L5.586 7z" fill="#333" fill-rule="evenodd"/></svg>
      </button>

      <div class="filter">
        <span class="filter__title">work</span>
        <div class="filter__time">{{ initWork }}</div>
        <input @change="handleChange" :value="initWork" data-type="work" class="filter__input" type="range" min="1" max="120">
      </div>

      <div class="filter">
        <span class="filter__title">short break</span>
        <div class="filter__time">{{ initShortBreak }}</div>
        <input @change="handleChange" :value="initShortBreak" data-type="short-break"  class="filter__input" type="range" min="1" max="120">
      </div>

      <!-- <div class="filter" v-for="filter in filters" :key="filter.id">
        <span class="filter__title">{{ filter.title }}</span>
        <div class="filter__time">{{ filter.default }}</div>
        <input @change="handleChange" :data-type="filter.title | hyphen " class="filter__input" :min="filter.min" :max="filter.max" v-model="myComputedInit" type="range"/>
      </div> -->

      <button @click="reset" class="filter__reset-btn">Reset to defaults</button>
    </div>
  </transition>
</script>

<script type="text/x-template" id="app-main">
  
  <main class="site__content">
    <div class="container">
      <div class="timer">
        <span class="timer__session js-session">{{ isBreakTime ? 'break' : 'work' }}</span>
        <span class="timer__countdown js-countdown">{{`${minutes}:${seconds}`}}</span>
      </div>
    </div>

    <app-modal v-if="isModalOpen" @close="closeModal">
      <h3 slot="header">Pomodoro</h3>
      <p slot="body">The pomodoro technique is a time management method that uses a timer to break down work into intervals separated by short breaks.</p>
    </app-modal>
    
  </main>
  
</script>

<script type="text/x-template" id="app-modal">
  <transition name="modal">
    <div class="modal">
      <div class="modal__inner flex-center">
        <div class="modal__content">
          <div class="modal-header">
            <slot name="header"></slot>
            <button @click="$emit('close')" class="modal__close">
              <svg width="14" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M5.586 7L.636 2.05A1 1 0 0 1 2.05.636L7 5.586l4.95-4.95a1 1 0 0 1 1.414 1.414L8.414 7l4.95 4.95a1 1 0 0 1-1.414 1.414L7 8.414l-4.95 4.95A1 1 0 0 1 .636 11.95L5.586 7z" fill="#333" fill-rule="evenodd"/></svg>
            </button>
          </div>
          <div class="modal-body">
            <slot name="body"></slot>
          </div>
        </div>
        <div @click="$emit('close')" class="modal__overlay"></div>
      </div>
    </div>
  </transition>
</script>

<script type="text/x-template" id="app-footer">
  <footer>
    <div class="container">
      <div class="controls">

        <button @click="reset" title="reset" class="btn-sm">
          <svg width="16" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M8 4.023c4.398 0 8 3.556 8 7.954 0 4.397-3.602 8-8 8s-8-3.603-8-8h2.012A5.97 5.97 0 0 0 8 17.965a5.97 5.97 0 0 0 5.988-5.988A5.97 5.97 0 0 0 8 5.988v4.024L2.994 5.006 8 0v4.023z" fill="#FFF" fill-rule="nonzero"/></svg>
        </button>

        <button @click="toggleTimer" class="btn-md">
          <span v-if="isTimerActive" class="flex-center">
            <svg width="12" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M8.016.016H12v13.968H8.016V.016zM0 13.984V.016h3.984v13.968H0z" fill="#FFF" fill-rule="nonzero"/></svg>
          </span>
          <span v-else class="flex-center">
            <svg width="14" height="18" xmlns="http://www.w3.org/2000/svg"><path d="M0 0l14 9-14 9z" fill="#FFF" fill-rule="nonzero"/></svg>
          </span>
        </button>

        <button title="info" class="btn-sm" @click="toggleModal">
          <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg"><path d="M9.016 7V4.984h1.968V7H9.016zM10 18.016c4.406 0 8.016-3.61 8.016-8.016 0-4.406-3.61-8.016-8.016-8.016-4.406 0-8.016 3.61-8.016 8.016 0 4.406 3.61 8.016 8.016 8.016zm0-18A9.963 9.963 0 0 1 19.984 10 9.963 9.963 0 0 1 10 19.984 9.963 9.963 0 0 1 .016 10 9.963 9.963 0 0 1 10 .016zm-.984 15v-6h1.968v6H9.016z" fill="#FFF" fill-rule="nonzero"/></svg>
        </button>
      </div>
    </div>
  </footer>
</script>

<div id="app" class="site" :class="{'site--break': isBreakTime}">

  <app-header
    :init-work="initWork"
    :init-short-break="initShortBreak"
    @reset="resetSettings"
    @change="handleChange($event)"
  ></app-header>

  <app-main
    :is-modal-open="isModalOpen"
    :is-break-time="isBreakTime"
    :minutes="minutes"
    :seconds="seconds"
    @close-modal="toggleModal"
  ></app-main>

  <app-footer
    :is-timer-active="isTimerActive"
    @reset="resetUI"
    @toggle-timer="toggleTimer"
    @toggle-modal="toggleModal"
  ></app-footer>
  
</div> <!-- /#app -->

Css

$primary: #d9534f;
$secondary: #3eab45;
$white: #fff;
$text: #333;

@import url('https://fonts.googleapis.com/css?family=Pacifico');

*,
*::after,
*::before {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: 'Avenir Next', -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
  font-size: 1rem;
  line-height: 1.5;
  color: #fff;
  text-align: center;
  -webkit-font-smoothing: antialiased;
}

// helper 
.flex-center {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
}

// Layout
.site {
  display: flex;
  min-height: 100vh;
  flex-direction: column;
  background-color: $primary;
  transition: background-color .15s ease;
}

.site--break {
  background-color: $secondary;
}

.site__content {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.container {
  padding: 0 1rem;
  margin: 0 auto;
  width: 100%;
  max-width: 1200px;
}

// Header
.nav {
  display: flex;
  height: 3.625rem;
  align-items: center;
  justify-content: space-between;
}

.nav-logo {
  font-family: Pacifico-Regular, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
  font-size: 1.5rem;
}

.nav-toggle {
  height: 100%;
}

// Sidebar
.sidebar {
  padding: 77px 20px;
  display: flex;
  flex-direction: column;
  position: fixed;
  top: 0;
  right: 0;
  width: 256px;
  height: 100vh;
  background-color: #fff;
  color: #333;
  will-change: transform;
  overflow-y: auto;
  z-index: 4;
  transition: all 0.4s cubic-bezier(0.4, 0, 0, 1);
}


.sidebar-enter-active,
.sidebar-leave-active {
  transition: all 0.4s cubic-bezier(0.4, 0, 0, 1);

}

.sidebar-enter,
.sidebar-leave-to {
  transform: translateX(100%);
}

.sidebar-toggle {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  position: absolute;
  top: 5px;
  right: 16px;
  padding: 0;
  width: 48px;
  height: 48px;
}

.filter {
  margin-bottom: 24px;
}

.filter__title {
  color: #333;
}

.filter__time {
  color: #474747;
}

.filter__reset-btn {
  position: absolute;
  bottom: 3rem;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #333;
}

/* input[type=range] reset */
input[type=range] {
  -webkit-appearance: none; /* Hides the slider so that custom slider can be made */
  width: 100%; /* Specific width is required for Firefox. */
  background: transparent; /* Otherwise white in Chrome */
}

input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
}

input[type=range]:focus {
  outline: none; /* Removes the blue border. You should probably do some kind of focus styling for accessibility reasons though. */
}

input[type=range]::-ms-track {
  width: 100%;
  cursor: pointer;

  /* Hides the slider so custom styles can be added */
  background: transparent;
  border-color: transparent;
  color: transparent;
}
/* input[type=range] custom
 * THUMB
 */

/* Special styling for WebKit/Blink */
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 14px;
  width: 14px;
  border-radius: 50%;
  background: #d9534f;
  cursor: pointer;
  margin-top: -6px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */
}

/* All the same stuff for Firefox */
input[type=range]::-moz-range-thumb {
  border: 0;
  height: 14px;
  width: 14px;
  border-radius: 50%;
  background: #d9534f;
  cursor: pointer;
}

/* All the same stuff for IE */
input[type=range]::-ms-thumb {
  border: 0;
  height: 14px;
  width: 14px;
  border-radius: 50%;
  background: #d9534f;
  cursor: pointer;
}

/* input[type=range] custom
 * TRACK
 */

 input[type=range]::-webkit-slider-runnable-track {
  width: 100%;
  height: 2px;
  cursor: pointer;
  background: #E1E1E1;
  border: 0;
}

input[type=range]:focus::-webkit-slider-runnable-track {
  background: #E1E1E1;
}

input[type=range]::-moz-range-track {
  width: 100%;
  height: 2px;
  cursor: pointer;
  background: #E1E1E1;
  border: 0;
}

input[type=range]::-ms-track {
  width: 100%;
  height: 2px;
  cursor: pointer;
  background: transparent;
  border-color: transparent;
  border-width: 16px 0;
  color: transparent;
}
input[type=range]::-ms-fill-lower {
  background: #E1E1E1;
  border: 0;
}
input[type=range]:focus::-ms-fill-lower {
  background: #E1E1E1;
}
input[type=range]::-ms-fill-upper {
  background: #E1E1E1;
  border: 0;
  border-radius: 0;
  box-shadow: 0;
}
input[type=range]:focus::-ms-fill-upper {
  background: #E1E1E1;
}

// Main
.timer {
  position: relative;
  margin: 0 auto;
  display: flex;
  justify-content: center;
  flex-flow: column wrap;
  max-width: 224px;
  height: 224px;
  border: 4px solid $white;
  border-radius: 50%;
  
  &::before,
  &::after {
    content: "";
    display: block;
    position: absolute;
    top: 50%;
    left: 50%;
    border: 1px solid rgba(255, 255, 255, .25);
    border-radius: 50%;
    transform: translate(-50%, -50%);
  }
  
  &::before {
    width: 120%;
    height: 120%;
  }
  
  &::after {
    width: 80%;
    height: 80%;
  }
}

.timer__session {
  font-weight: 500;
}

.timer__countdown {
  font-size: 2.5rem;
}

// Footer
button {
  padding: 0;
  color: $white;
  font-family: inherit;
  font-size: 1rem;
  background-color: transparent;
  border: 0;
  outline: 0;
  user-select: none;
  transition: border-color .15s ease;
  
  &:not(:disabled) {
    cursor: pointer;
  }
}

.controls {
  padding-bottom: 24px;
  display: flex;
  flex-flow: row wrap;
  align-items: center;
  justify-content: center;
}

.btn-sm {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: calc(3rem + 4px);
  height: calc(3rem + 4px);
  line-height: 3rem;
  border: 2px solid rgba(255, 255, 255, .25);
  border-radius: 50%;
}

.btn-md {
  margin: 1.5rem 1.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: calc(4rem + 4px);
  height: calc(4rem + 4px);
  line-height: 3rem;
  border: 2px solid rgba(255, 255, 255, .25);
  border-radius: 50%;
}

.btn-sm:hover,
.btn-md:hover {
  border-color: #fff;
}

// Modal

.modal {
  position: fixed;
  display: block;
  top: 0;
  right: 0;
  width: 100%;
  height: 100%;
  overflow-y: auto;
  z-index: 1050;
  transition: all .3s ease;
}

.modal__inner {
  position: relative;
  top: 0;
  right: 0;
  width: auto;
  height: calc(100vh - 3em);
  max-width: 768px;
  margin: 1.5em auto;
  padding: 0 1em;
}

.modal__content {
  padding: 1rem 0;
  position: relative;
  width: 100%;
  background-color: $white;
  color: #333;
  border-radius: 4px;
  transition: all .3s ease;
  z-index: 1050;
}

.modal-header,
.modal-body,
.modal-footer {
  padding: 1em;
}

.modal-header > h3,
.modal-body > h3,
.modal-footer > h3 {
  margin: 0;
}

.modal__close {
  position: absolute;
  padding: 1em;
  top: 0;
  right: 0;
  background-color: transparent;
  border: 0;
  cursor: pointer;
  user-select: none;
}

.modal-body {
  text-align: left;
}

.modal__overlay {
  position: fixed;
  top: 0;
  right: 0;
  width: 100%;
  height: 100%;
  background-color: $white;
  opacity: .64;
  transition: opacity .3s ease;
  z-index: 1040;
}

.modal-enter,
.modal-enter .modal__overlay,
.modal-leave-active .modal__overlay {
  opacity: 0;
}

.modal-leave-active {
  opacity: 0;
}

.modal-enter .modal__content,
.modal-leave-active .modal__content {
  -webkit-transform: scale(0.9);
  transform: scale(0.9);
}

Javascript

Vue.component('app-header', {
  props: [
    'initWork',
    'initShortBreak',
    'initLongBreak',
    'initRound',
  ],
  data() {
    return {
      isSidebarOpen: false,
    }
  },
  methods: {
    toggleSidebar() {
      this.isSidebarOpen = !this.isSidebarOpen;
    },
    filterChange(data) {
      this.$emit('change', data);
    }
  },
  template: '#app-header',
  components: {
    'app-sidebar': {
      props: [
        'initWork',
        'initShortBreak',
        'isSidebarOpen',
      ],
      methods: {
        reset() {
          this.$emit('reset');
        },
        handleChange(event) {
          let data = event.target.dataset.type || e.srcElement.dataset.type;
          let value = Number(event.target.value) || Number(event.srcElement.value);

          this.$emit('change', { data: data, value: value });
        }
      },
      template: '#app-sidebar'
    }
  }
});

Vue.component('app-main', {
  props: [
    'isModalOpen',
    'isBreakTime',
    'minutes',
    'seconds',
  ],
  methods: {
    closeModal: function() {
      this.$emit('close-modal');
    }
  },
  components: {
    'app-modal': {
      template: '#app-modal'
    }
  },
  template: '#app-main'
});

Vue.component('app-footer', {
  props: [
    'isTimerActive',
  ],
  methods: {
    toggleTimer() {
      this.$emit('toggle-timer');
    },
    reset() {
      this.$emit('reset');
    },
    toggleModal() {
      this.$emit('toggle-modal');
    }
  },
  template: '#app-footer'
});

let vm = new Vue({
  el: '#app',
  data: {
    // Settings
    initWork: 25,
    initShortBreak: 5,

    // App state
    isBreakTime: false,
    isTimerActive: false,
    minutes: 25,
    seconds: '00',
    timer: null,
    round: 0,

    // UI
    isModalOpen: false,
  },
  methods: {

    toggleModal: function() {
      this.isModalOpen = !this.isModalOpen;
    },

    resetSettings() {
      this.initWork= 25;
      this.initShortBreak= 5;
      this.isBreakTime ? this.minutes = this.initShortBreak : this.minutes = this.initWork;
    },

    resetUI() {
      this.isBreakTime = false;
      this.isTimerActive = false;
      this.minutes = this.initWork;
      this.seconds = '00';
      clearInterval(this.timer);
    },

    toggleTimer: function() {
      let self = this;

      function countDown() {

        let seconds = Number(self.$data.seconds);
        let minutes = self.minutes;
        let isBreak = self.isBreakTime;

        if (seconds === 0) {
          if (minutes === 0) { // End of cycle => switch to break / work
            isBreak ? self.minutes = self.initWork : self.minutes = self.initShortBreak;
            self.isBreakTime = !self.isBreakTime;

          } else { // Remove minute + start counting down from 60 seconds again
            self.minutes--;
            self.seconds = '59';
          }
        } else { // Remove seconds + if less than 10 prepend 0
          seconds <= 10 ? self.seconds = `0${self.seconds - 1}` : self.seconds = `${self.seconds - 1}`;
        }
      }

      // toggle timer
      self.isTimerActive ? clearInterval(self.timer) : self.timer = setInterval(countDown, 1000);
      self.isTimerActive = !self.isTimerActive;
    },

    handleChange: function(obj) {

      let data = obj.data;
      let value = obj.value;

      switch(data) {
        case "work":
          this.initWork = value;

          if (!this.isBreakTime) {
            this.minutes = value;
            this.seconds = '00';
          }

          break;
        case "short-break":
          this.initShortBreak = value;

          if (this.isBreakTime) {
            this.minutes = value;
            this.seconds = '00';
          }

          break;
      }
    },
  },
});

Author

Jan Czizikow

Demo

See the Pen Pomodoro Clock by Jan Czizikow (@hollow3d) on CodePen.