Home > OS >  How to start a timer without page refresh (Rails/JavaScript)?
How to start a timer without page refresh (Rails/JavaScript)?

Time:03-09

I have a model named 'Deal' which has start_at and end_at attributes. I have implemented a countdown timer using hotwire/stimulus JS.

  1. When the deal starts (start date is in the past, end date is in the future), the countdown timer displaying time left to deal will be shown. e.g Time left to deal: 2 hours, 4 minutes, 30 seconds and so on. It will decrement by 1 second.
  2. If the deal has not yet started (start date is in the future), the page will show "Deal is going to start on #{datetime}".

However, the user needs to refresh the page they are currently on to see a timer if the deal has started in the meantime (i.e. transitioning from "Deal is going to start on #{datetime}" to a countdown timer). I am wondering what's the best way to start the timer without refreshing the page. Thanks.

CodePudding user response:

The way to manage a 'timer' that runs some function every X milliseconds is via the browser's setInterval function.

This function can be used like this - const intervalID = setInterval(myCallback, 500); - where myCallback is the function that will run every 500ms.

The timer can be 'cancelled' by calling clearInterval and giving it the interval ID that is created as the result of setInterval.

Example HTML

  • Here we have a basic HTMl structure where we set our controller timer and set the from/to times along with targets that hold the messages based on three states.
  • These three states are 'before', 'during' (when the current time is between the two times) and 'after'.
<section >
  <div
    data-controller="timer"
    data-timer-from-value="2022-03-08T10:41:32.111Z"
    data-timer-to-value="2022-03-09T11:10:32.111Z"
  >
    <div style="display: none" data-timer-target="before">
      Deal will start on <time data-timer-target="fromTime"></time>
    </div>
    <div style="display: none" data-timer-target="during">
      Deal is active <time data-timer-target="toTimeRelative"></time>
    </div>
    <div style="display: none" data-timer-target="after">
      Deal ended on <time data-timer-target="toTime"></time>
    </div>
  </div>
</section>

Example Stimulus Controller

  • This timerController accepts the to and from times as strings (ISO strings are best to use, and remember the nuances of time-zones can be complex).
  • When the controller connects we do three things; 1. set up a timer to run this.update every X milliseconds and put the timer ID on the class for clearing later as this._timer. 2. Set the time values (the inner time labels for messaging). 3. Run the this.update method the initial time.
  • this.getTimeData parses the from/to datetime strings and does some basic validation, it also returns these date objects along with a status string which will be one of BEFORE/DURING/AFTER.
  • this.update - this shows/hides the relevant message parts based on the resolved status.
import { Controller } from '@hotwired/stimulus';

const BEFORE = 'BEFORE';
const DURING = 'DURING';
const AFTER = 'AFTER';

export default class extends Controller {
  static values = {
    interval: { default: 500, type: Number },
    locale: { default: 'en-GB', type: String },
    from: String,
    to: String,
  };

  static targets = [
    'before',
    'during',
    'after',
    'fromTime',
    'toTime',
    'toTimeRelative',
  ];

  connect() {
    this._timer = setInterval(() => {
      this.update();
    }, this.intervalValue);

    this.setTimeValues();
    this.update();
  }

  getTimeData() {
    const from = this.hasFromValue && new Date(this.fromValue);
    const to = this.hasToValue && new Date(this.toValue);

    if (!from || !to) return;
    if (from > to) {
      throw new Error('From time must be after to time.');
    }

    const now = new Date();

    const status = (() => {
      if (now < from) return BEFORE;

      if (now >= from && now <= to) return DURING;

      return AFTER;
    })();

    return { from, to, now, status };
  }

  setTimeValues() {
    const { from, to, now } = this.getTimeData();
    const locale = this.localeValue;

    const formatter = new Intl.DateTimeFormat(locale, {
      dateStyle: 'short',
      timeStyle: 'short',
    });

    this.fromTimeTargets.forEach((element) => {
      element.setAttribute('datetime', from);
      element.innerText = formatter.format(from);
    });

    this.toTimeTargets.forEach((element) => {
      element.setAttribute('datetime', to);
      element.innerText = formatter.format(to);
    });

    const relativeFormatter = new Intl.RelativeTimeFormat(locale, {
      numeric: 'auto',
    });

    this.toTimeRelativeTargets.forEach((element) => {
      element.setAttribute('datetime', to);
      element.innerText = relativeFormatter.format(
        Math.round((to - now) / 1000),
        'seconds'
      );
    });
  }

  update() {
    const { status } = this.getTimeData();

    [
      [BEFORE, this.beforeTarget],
      [DURING, this.duringTarget],
      [AFTER, this.afterTarget],
    ].forEach(([key, element]) => {
      if (key === status) {
        element.style.removeProperty('display');
      } else {
        element.style.setProperty('display', 'none');
      }
    });

    this.setTimeValues();

    if (status === AFTER) {
      this.stopTimer();
    }
  }

  stopTimer() {
    const timer = this._timer;

    if (!timer) return;

    clearInterval(timer);
  }

  disconnect() {
    // ensure we clean up so the timer is not running if the element gets removed
    this.stopTimer();
  }
}

  • Related