1

I've made a jQuery countdown timer, using moment.js and moment-duration-format plugin.

Note that $('div#countdown') is hard-coded inside the function. The function works as presented here. But if I change the hard-coded reference to $(this), it doesn't work. console.log($this) returns an empty object.

I've tried setting $(this) to a variable at the start of the function, in case the interval was creating a scope issue, but it made no difference.

The confusing part is that I've used this exact $.fn.xxx = function syntax before for custom jQuery functions, and $(this) has worked fine in those functions. Something about this particular function is tripping it up.

<script>
    $.fn.countdown = function ( seconds, tFormat, stopAtZero ) {
        tFormat = (typeof tFormat !== 'undefined') ? tFormat : 'hh:mm:ss';
        stopAtZero = (typeof stopAtZero !== 'undefined') ? stopAtZero : true;
        var eventTime = Date.now() + ( seconds * 1000 );
        var diffTime = eventTime - Date.now();
        var duration = moment.duration( diffTime, 'milliseconds' );
        var interval = 0;
        var counter = setInterval(function () {
            $('div#countdown').text( moment.duration( duration.asSeconds() - ++interval, 'seconds' ).format( tFormat, { trim: false }) );
            if( stopAtZero && interval >= seconds ) clearInterval( counter );
        }, 1000);
    };

    $('div#countdown').countdown( 30*60, 'mm:ss' );
</script>

<div id="countdown"></div>

Edit: RESOLVED. The function was fine. It just needed to come after the Div (or after Document Load) ::headdesk::

Stephen R
  • 2,564
  • 1
  • 20
  • 35
  • `this` does change meaning depending on scope; you'll have a different `this` at the window level, inside your countdown function, and inside your setInterval. Typical workaround is to use `var self = this` to capture a reference whichever `this` you actually intend to use, and refer to `self` inside the child function. (Although, in your case, explicitly referencing the DOM element you want, as you've done here, is probably the best solution.) – Daniel Beck Apr 28 '17 at 15:14
  • I tried that. Set the first line of the function to: `target = $(this);` then used target inside the setInterval – Stephen R Apr 28 '17 at 15:15
  • Related [How does the “this” keyword work?](http://stackoverflow.com/questions/3127429/how-does-the-this-keyword-work) – Liam Apr 28 '17 at 15:16

3 Answers3

2

The issue is because the function within setTimeout() runs under a different scope than your outer function. Hence this is not what you expect it to be. You need to cache the this reference.

Also note that you can improve the logic slightly by providing an object to the function which contains the options. Then you can use $.extend to provide defaults. You should also loop over this to enable users to provide a collection of objects to initialise the plugin on. Try this:

$.fn.countdown = function(options) {
  var defaults = {
    seconds: 0,
    tFormat: 'hh:mm:ss',
    stopAtZero: true,
    complete: function() {}
  };
  var settings = $.extend(defaults, options);

  $(this).each(function() {
    var $el = $(this);
    var eventTime = Date.now() + (settings.seconds * 1000);
    var diffTime = eventTime - Date.now();
    var duration = moment.duration(diffTime, 'milliseconds');
    var interval = 0;
    var counter = setInterval(function() {
      $el.text(moment.duration(duration.asSeconds() - ++interval, 'seconds').format(settings.tFormat, {
        trim: false
      }));
      if (settings.stopAtZero && interval >= settings.seconds) {
        clearInterval(counter);
        settings.complete();
      }
    }, 1000);
  });
}

$('div').countdown({
  seconds: 10,
  complete: function() {
    console.log('finished!');
  }
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-duration-format/1.3.0/moment-duration-format.min.js"></script>
<div></div>
Rory McCrossan
  • 306,214
  • 37
  • 269
  • 303
  • Rory, Thanks for the suggestions. Note commentary in a near-identical answer below yours. – Stephen R Apr 28 '17 at 15:22
  • 1
    Can you investigate to see if there's any issues in other areas of your code. As you can see from the snippet I just edited in to my answer, it works fine. Note that you're relying on both `moment.js` and `moment-duration-format.js` - you need to ensure you include both files within your page, and in the right order – Rory McCrossan Apr 28 '17 at 15:23
  • @StephenR, rorys works. Run the snippet. If your saying it doesn't you need to explain what your doing differently and why yours doesn't work. This is the correct answer. – Liam Apr 28 '17 at 15:24
  • Could it be that I'm using jQuery 3.1? – Stephen R Apr 28 '17 at 15:27
  • 1
    Doesn't seem so - I just updated the fiddle and that still works. – Rory McCrossan Apr 28 '17 at 15:29
  • AAAARRGGH! Figured it out. I tried copy/pasting your exact code into my file, and it **didn't work**. Huh. Turns out it's because the
    was written after the script. No $(this) because the div didn't exist yet. Stoopid stoopid stoopid. Marking this correct because you gave such nice suggestions for improvements (even though in this case I can't imagine ever needing to put the exact same countdown counter on multiple elements ;-)
    – Stephen R Apr 28 '17 at 16:01
  • Ahh, that'll do it :) Glad you got it fixed. – Rory McCrossan Apr 28 '17 at 16:02
  • Quick follow up... would there be a straightforward way to tell this script to call another function when the timer is up? That is, a function that could be specified in the options when calling the Countdown function. – Stephen R Apr 28 '17 at 16:54
  • 1
    Absolutely, you can provide a function to execute within the settings object. I updated the answer for you – Rory McCrossan Apr 28 '17 at 16:55
1

you need to capture your element at a higher scope and use it in the interval.

$.fn.countdown = function ( seconds, tFormat, stopAtZero ) {
    tFormat = (typeof tFormat !== 'undefined') ? tFormat : 'hh:mm:ss';
    stopAtZero = (typeof stopAtZero !== 'undefined') ? stopAtZero : true;

    //Capture this here, where it points at  $('div#countdown')
    var elem = $(this);
    //writes countdown
    console.log(elem.attr('id'));

    var eventTime = Date.now() + ( seconds * 1000 );
    var diffTime = eventTime - Date.now();
    var duration = moment.duration( diffTime, 'milliseconds' );
    var interval = 0;
    var counter = setInterval(function () {

        //this here points at window

        //writes countdown
        console.log(elem.attr('id'));

        //now use your captured element here.
        elem.text( moment.duration( duration.asSeconds() - ++interval, 'seconds' ).format( tFormat, { trim: false }) );
        if( stopAtZero && interval >= seconds ) clearInterval( counter );
    }, 1000);
};

$('div#countdown').countdown( 30*60, 'mm:ss' );
Liam
  • 22,818
  • 25
  • 93
  • 157
1

Here is a working example (I removed the not-relevant-to-the-question moment() and formatting code; otherwise this is the same except for capturing "this" at the proper scope.)

$.fn.countdown = function ( seconds, tFormat, stopAtZero ) {
        tFormat = (typeof tFormat !== 'undefined') ? tFormat : 'hh:mm:ss';
        stopAtZero = (typeof stopAtZero !== 'undefined') ? stopAtZero : true;
        var eventTime = Date.now() + ( seconds * 1000 );
        var diffTime = eventTime - Date.now();
        var duration = 5000;
        var interval = 0;
        var self = this; // <-- capture the DOM element here
        var counter = setInterval(function () {
            // and use it here:
            $(self).text((5000 - ++interval) + ' seconds');
            if( stopAtZero && interval >= seconds ) clearInterval( counter );
        }, 1000);
    };

    $('div#countdown').countdown( 30*60, 'mm:ss' );
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<div id="countdown"></div>
Daniel Beck
  • 16,972
  • 5
  • 29
  • 49