I have a model named 'Deal' which has start_at
and end_at
attributes. I have implemented a countdown timer using hotwire/stimulus
JS.
- 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.
- 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 500
ms.
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 theto
andfrom
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 asthis._timer
. 2. Set the time values (the inner time labels for messaging). 3. Run thethis.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 astatus
string which will be one ofBEFORE
/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();
}
}