Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at the New York ColdFusion User Group (Feb. 2009) with: Clark Valberg and Joakim Marner
Ben Nadel at the New York ColdFusion User Group (Feb. 2009) with: Clark Valberg@clarkvalberg ) and Joakim Marner

Harnessing My Social Anxiety Using A Countdown Timer In Vue.js 2.6.7

By Ben Nadel on

For most of this past week, I've been hanging out with over 700 of the most amazing people down in Phoenix Arizona at the second InVision IRL (In Real Life) conference. And while it was definitely an honor and a privilege to be there, my social anxiety still kicked into high-gear. In an attempt to focus that nervous energy into something positive, I created a small Vue.js application that would take a date and render a countdown timer that would indicate when I would be back safely in my own home. This was also my opportunity to play with the Vue.js Router for the first time. There probably isn't too much here to share from a learning perspective; but, I thought I would share the code nonetheless.


 
 
 

 
 
 
 
 

Run this demo on Netlify.

View this code in my I'll Be Back project on GitHub.

The concept behind this Vue.js application is that the user would be presented with a form where they could select a target date and time. Once provided, the user would then be taken to another view in which a countdown to said target date would be rendered. In addition to seeing this countdown timer myself, I wanted to be able to send it to loved-ones (who would obviously be missing me dearly and not throwing parties in my absence); which means, the target date had to be driven by the URL. This made for a good context in which to try the Vue.js router for the first time.

To get this working, I imported the VueRouter, defined some routes, and then told Vue.js to use the given router:

  • // Import for side effects - we have to import this first so that the polyfills will
  • // be available for the rest of the code.
  • // --
  • // NOTE: I would normally include this as an Entry bundle; but, I couldn't get the
  • // HtmlWebpackPlugin to work properly if I did that (since I don't think it could
  • // implicitly determine the dependency order). In the future, I might be able to make
  • // this more dynamic (ie, use Webpack's import() syntax).
  • import "./main.polyfill";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • // Import core classes.
  • import Vue from "vue";
  • import VueRouter from "vue-router";
  •  
  • // Import application classes.
  • import AppComponent from "./app.component.vue";
  • import CountdownComponent from "./countdown.component.vue";
  • import HomeComponent from "./home.component.vue";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • var routes = [
  • {
  • path: "/",
  • component: HomeComponent
  • },
  • {
  • path: "/countdown/:utcTarget",
  • component: CountdownComponent
  • },
  • {
  • path: "*",
  • redirect: "/"
  • }
  • ];
  •  
  • var router = new VueRouter({
  • routes: routes
  • });
  •  
  • Vue.use( VueRouter );
  •  
  • new Vue({
  • el: "my-app",
  • router: router,
  •  
  • // I render the root component of the application into the DOM.
  • render: ( createElement ) => {
  •  
  • return( createElement( AppComponent ) );
  •  
  • }
  • });

As you can see, this route-based application only has two routes: Home and Countdown. Everything else redirects automatically to the homepage.

Now, because I wrote this Vue.js application in the hours before my flight, I didn't really have time to design anything interesting (including the date-selection widget). As such, there's no shared layout, no special fonts, and almost no form validation. In fact, my root application component does nothing but render the routable component:

  • <template>
  •  
  • <router-view></router-view>
  •  
  • </template>
  •  
  • <script>
  •  
  • export default {
  • // ...
  • };
  •  
  • </script>

If I had more time, I may have been able to factor-out some of the layout in the App component; but, given the time constraints, I created two components that, more or less, operate completely independently of each other.

The first is the Home component which is where the user enters the target date to which we are counting down. This component presents a form in which the user has to enter the Year, Month, Day, Hour, and Minute representing the target date. Again, given the time constraints, I present these as raw input boxes with very little additional functionality:

  • <style scoped src="./home.component.less" />
  •  
  • <template>
  •  
  • <div class="home-view">
  • <form @submit.prevent="setupCountdown()" class="content">
  •  
  • <h1 class="title">
  • When Will You Be Back?
  • </h1>
  •  
  • <div class="fields">
  • <input type="string" v-model="form.year" title="Year" placeholder="YYYY" class="field" />
  • <input type="string" v-model="form.month" title="Month" placeholder="MM" class="field" />
  • <input type="string" v-model="form.day" title="Day" placeholder="DD" class="field" />
  • <span class="seperator">
  • @
  • </span>
  • <input type="string" v-model="form.hour" title="Hour" placeholder="HH" class="field" />
  • <input type="string" v-model="form.minute" title="Minute" placeholder="NN" class="field" />
  • </div>
  •  
  • <button type="submit" class="submit">
  • Start Counting
  • </button>
  •  
  • </form>
  • </div>
  •  
  • </template>
  •  
  • <script>
  •  
  • export default {
  • // I return the default reactive data for the component.
  • data() {
  •  
  • return({
  • form: {
  • year: "",
  • month: "",
  • day: "",
  • hour: "",
  • minute: ""
  • }
  • });
  •  
  • },
  •  
  • methods: {
  •  
  • // I take the user to the countdown, assuming the data is valid.
  • setupCountdown() {
  •  
  • var year = parseInt( this.form.year, 10 );
  • var month = parseInt( this.form.month, 10 );
  • var day = parseInt( this.form.day, 10 );
  • var hour = parseInt( this.form.hour, 10 );
  • var minute = parseInt( this.form.minute, 10 );
  •  
  • if (
  • isNaN( year ) ||
  • isNaN( month ) ||
  • isNaN( day ) ||
  • isNaN( hour ) ||
  • isNaN( minute )
  • ) {
  •  
  • alert( "Invalid date parts." );
  • return;
  •  
  • }
  •  
  • var target = new Date(
  • year,
  • ( month - 1 ), // Adjust for normal human mental model.
  • day,
  • hour,
  • minute
  • );
  •  
  • this.$router.push( "/countdown/" + target.getTime() );
  •  
  • }
  • }
  • };
  •  
  • </script>

The individual date parts are used to create an actual Date object. And, it is from this Date object that we can extract the UTC milliseconds of the target date. The UTC milliseconds then act as the ":utcTarget" parameter in our next routable component: the Countdown component.

The Countdown component is really just a glorified timer than ticks every 1,000ms and figures out how close we are to the target date. As a learning opportunity, I wanted to see how we could start and stop the timer based on the focus of the window. While not really necessary from a performance standpoint, this provided a context in which to experiment with methods not naturally bound to the component instance.

  • <style scoped src="./countdown.component.less" />
  •  
  • <template>
  •  
  • <div class="countdown-view">
  •  
  • <router-link to="/" class="home">
  • <svg viewBox="0 0 448 1024" role="img" aria-labelledby="countdown-back-to-home" class="icon">
  • <title id="countdown-back-to-home">Back to Home</title>
  • <path d="M448 320L320 192 0 512l320 320 128-128L256 512 448 320z" />
  • </svg>
  • </router-link>
  •  
  • <div class="content">
  •  
  • <table class="times">
  • <tr class="time" :class="{ active: ( sleeps ) }">
  • <td class="value">
  • {{ sleeps }}
  • </td>
  • <th class="label">
  • Sleeps
  • </th>
  • </tr>
  • <tr class="time" :class="{ active: ( hours ) }">
  • <td class="value">
  • {{ hours }}
  • </td>
  • <th class="label">
  • Hours
  • </th>
  • </tr>
  • <tr class="time" :class="{ active: ( hours || minutes ) }">
  • <td class="value">
  • {{ minutes }}
  • </td>
  • <th class="label">
  • Minutes
  • </th>
  • </tr>
  • <tr class="time" :class="{ active: ( hours || minutes || seconds ) }">
  • <td class="value">
  • {{ seconds }}
  • </td>
  • <th class="label">
  • Seconds
  • </th>
  • </tr>
  • </table>
  •  
  • </div>
  • </div>
  •  
  • </template>
  •  
  • <script>
  •  
  • // Import application classes.
  • import { CountdownCalculator } from "./countdown-calculator";
  •  
  • // ------------------------------------------------------------------------------- //
  • // ------------------------------------------------------------------------------- //
  •  
  • // CAUTION: This class will only ever be instantiated once when the module for this
  • // component is resolved.
  • var countdownCalculator = new CountdownCalculator();
  •  
  • export default {
  • // I return the default reactive data for the component.
  • data() {
  •  
  • return({
  • sleeps: 0,
  • hours: 0,
  • minutes: 0,
  • seconds: 0,
  • timer: null
  • });
  •  
  • },
  •  
  • // I get called once when the component has been instantiated.
  • created() {
  •  
  • // Validate that the target date is a number.
  • if ( isNaN( +this.$route.params.utcTarget ) ) {
  •  
  • this.$router.push( "/" );
  • return;
  •  
  • }
  •  
  • // Bind the external handlers to the current scope. Unlike the instance
  • // methods, which are all automatically bound to the current instance, these
  • // external handlers are naturally bound to the global scope. As such, let's
  • // manually bind them such that "this" references will be bound to this
  • // component internally.
  • // --
  • // NOTE: If we used TypeScript and Classes we wouldn't have to do this - we
  • // could just fat-arrow the method definitions and it would "just work".
  • this.handleBlur = this.handleBlur.bind( this );
  • this.handleFocus = this.handleFocus.bind( this );
  • this.handleTick = this.handleTick.bind( this );
  •  
  • this.startTimer();
  •  
  • // The browser probably does this for us anyway (behind the scenes) but, as a
  • // performance tweak, let's start and stop the timer so that we're only doing
  • // the maths when the user is looking at the window.
  • // --
  • // NOTE: The Page Visibility API may have been a better choice; but, that can
  • // be a future improvement.
  • window.addEventListener( "focus", this.handleFocus );
  • window.addEventListener( "blur", this.handleBlur );
  •  
  • },
  •  
  • // I get called once when the component has been destroyed.
  • destroyed() {
  •  
  • this.stopTimer();
  •  
  • window.removeEventListener( "focus", this.handleFocus );
  • window.removeEventListener( "blur", this.handleBlur );
  •  
  • },
  •  
  • methods: {
  •  
  • // I handle the blurring of the browser window.
  • handleBlur() {
  •  
  • console.warn( "Stopping timer due to lack of focus." );
  • this.stopTimer();
  •  
  • },
  •  
  •  
  • // I handle the focusing of the browser window.
  • handleFocus() {
  •  
  • console.warn( "Starting timer due to regained focus." );
  • this.startTimer();
  •  
  • },
  •  
  •  
  • // I handle the timer interval.
  • handleTick() {
  •  
  • this.updateCountdown();
  •  
  • },
  •  
  •  
  • // I start (and keep track of) the interval timer.
  • startTimer() {
  •  
  • if ( this.timer ) {
  •  
  • return;
  •  
  • }
  •  
  • this.timer = setInterval( this.handleTick, 1000 );
  • this.updateCountdown();
  •  
  • },
  •  
  •  
  • // I stop (and clear) the interval timer.
  • stopTimer() {
  •  
  • clearInterval( this.timer );
  • this.timer = null;
  •  
  • },
  •  
  •  
  • // I update the countdown state.
  • updateCountdown() {
  •  
  • // Rather than watching the route parameter, we'll just pluck the param
  • // out of the route every time we need to update the view.
  • var countdown = countdownCalculator.calculate( +this.$route.params.utcTarget );
  •  
  • this.sleeps = countdown.sleeps;
  • this.hours = countdown.hours;
  • this.minutes = countdown.minutes;
  • this.seconds = countdown.seconds;
  •  
  • }
  •  
  • }
  • };
  •  
  • </script>

As you can see, in order to wire-up the proper "this" bindings in my "handler" functions - those passed out of the Component context - I use the .bind() method in the component constructor. This forces the handler functions to execute in the component context instead of the global context.

ASIDE: Coming from an Angular and TypeScript background, having to use .bind() feels incredibly janky and gives me flashbacks to writing early React.js code. In Angular and TypeScript, handlers can be bound to a component context automatically using fat-arrow functions. I assume the same can be done in Vue.js and TypeScript; but, I haven't tried it yet.

While the Countdown component handles the timer logic, the actual delta-calculation of dates is handled by another service - the CountdownCalculator. This service takes a target date and an optional from date (defaulting to "now") and determines how many "sleeps", hours, minutes, and seconds there are remaining:

  • // I calculate the delta in Sleeps and Time between two different dates (given UTC
  • // milliseconds). The calculation is done using the client's native Timezone. As such,
  • // calculations may change slightly while traveling.
  • export class CountdownCalculator {
  •  
  • // I initialize the countdown calculator service.
  • constructor() {
  •  
  • this.SECOND_MS = 1000;
  • this.MINUTE_MS = ( this.SECOND_MS * 60 );
  • this.HOUR_MS = ( this.MINUTE_MS * 60 );
  • this.DAY_MS = ( this.HOUR_MS * 24 );
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I calculate the delta between the given dates (UTC milliseconds). If a FROM date
  • // is not provided, the current date is used as the default.
  • calculate( toUtc, fromUtc ) {
  •  
  • var countdown = {
  • sleeps: 0,
  • hours: 0,
  • minutes: 0,
  • seconds: 0,
  • time: 0
  • };
  •  
  • var msDelta = ( toUtc - ( fromUtc || Date.now() ) );
  •  
  • // If we've already reached / passed our target date, return defaults.
  • if ( msDelta < 0 ) {
  •  
  • return( countdown );
  •  
  • }
  •  
  • // At this point, we know that we have a calculation to perform. Let's convert to
  • // a robust Date object so that we can access date-parts.
  • var toDate = new Date( toUtc );
  •  
  • // When we calculate parts of the delta, each unit should only contain the
  • // remainder of the time not accounted for in the previous units. So, for
  • // example, the MINUTES unit should never be more than 60 as anything more than
  • // 60 will have already been accounted for in the HOURS unit. To do this, we are
  • // going to keep taking the greater unites out of the msDelta value and then
  • // using the remainder to calculate the lesser units.
  • countdown.time = msDelta;
  • countdown.hours = Math.floor( msDelta / this.HOUR_MS );
  •  
  • // Remove HOURS from remainder.
  • msDelta -= ( countdown.hours * this.HOUR_MS );
  •  
  • countdown.minutes = Math.floor( msDelta / this.MINUTE_MS );
  •  
  • // Remove MINUTES from remainder.
  • msDelta -= ( countdown.minutes * this.MINUTE_MS );
  •  
  • countdown.seconds = Math.floor( msDelta / this.SECOND_MS );
  •  
  • // Sleeps are a bit fuzzy to calculate since it's just as much about when the
  • // countdown is taking place as it is about how much countdown is remaining. To
  • // keep things simple, we'll consider any crossing of MIDNIGHT to indicate a
  • // increment of sleep. As such, we know that the number of sleeps should be AT
  • // LEAST the number of 24-hours blocks we have. Every 24-hour block is guaranteed
  • // to cross midnight once.
  • countdown.sleeps = Math.floor( countdown.time / this.DAY_MS );
  •  
  • // Now that we know how many times the delta exceed 24-hours, we have to see
  • // where the start and end times fall. It's possible that they will fall in such
  • // a way that we have one more sleep than expected. For example, if we have a
  • // 25-hour period that starts at 11:00 AM, it will only cross midnight once.
  • // However, if the same 25-hour period starts at 23:30, it will cross midnight
  • // twice (accounting for one additional sleep). To calculate this, we will remove
  • // all of the 24-hours blocks from the middle of the delta, leaving us with just
  • // the "shoulder" times.
  • var msShoulders = ( countdown.time - ( countdown.sleeps * this.DAY_MS ) );
  •  
  • // Now that we've removed the inner 24-hour blocks (that we know cross midnight
  • // at least once), we only have the times on either side. At this point, we can
  • // see if the "shoulder" times cross the midnight line.
  • var fromDayOfWeek = toDate.getDay();
  • var toDayOfWeek = new Date( toDate.getTime() + msShoulders ).getDay();
  •  
  • // If the two "shoulder" times fall on different days of the week, we need to
  • // add an additional sleep to our count.
  • if ( fromDayOfWeek !== toDayOfWeek ) {
  •  
  • countdown.sleeps += 1;
  •  
  • }
  •  
  • return( countdown );
  •  
  • }
  •  
  • }

Now, if we run this Vue.js application in the browser, provide a target date, and run the countdown, we get the following browser output:


 
 
 

 
 Countdown timer using Vue.js Router and Date. 
 
 
 

I am very new to Vue.js; and, this is the first time I've ever looked at the Vue.js Router, so please consider this to be just me "thinking out loud" and not me demonstrating any best practices. Ultimately, this was just a way for me to take my introverted social anxiety and funnel it into something more positive (ie, something that I can learn from).

Epilogue

This little Vue.js 2.6.7 application is hosted on Netlify. If you haven't tried it, Netlify is a big bowl of awesome. It generates sites based on your GitHub branches, does automatic SSL provisioning, and has a free tier. It's kind of a game-changer.



Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.