countdown-8.x-1.8/js/lib/countdown.js
js/lib/countdown.js
/*!
* CountdownTimer v1.0.0-alpha2 — Drupal Countdown module.
* https://drupal.org/project/countdown
* (c) 2025 Mahyar SBT
* @license GPL-2.0-or-later
*/
(function (root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.CountdownTimer = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// Time constants
const MS_IN_SEC = 1000;
const MS_IN_MIN = 60000;
const MS_IN_HOUR = 3600000;
const MS_IN_DAY = 86400000;
// Precision intervals
const PRECISION_MS = {
milliseconds: 1,
hundredths: 10,
tenths: 100,
seconds: 1000,
minutes: 60000
};
// Static regex patterns
const PATTERNS = {
ISO: /^\d{4}-\d{2}-\d{2}[T\s]/,
RELATIVE: /(?:(\d+)d)?(?:\s*(\d+)h)?(?:\s*(\d+)m)?(?:\s*(\d+)s)?/,
TIME: /^(?:(\d{1,2}):)?(?:(\d{1,2}):)?(\d{1,2})(?:[:.](\d{1,3}))?$/,
NUMERIC: /^\d+$/
};
// Static padding helpers
const pad = (n, len) => String(n).padStart(len, '0');
const pad2 = n => n < 10 ? '0' + n : String(n);
const pad3 = n => n < 10 ? '00' + n : n < 100 ? '0' + n : String(n);
/**
* Parse time inputs to milliseconds
* @param {*} input - Time in various formats
* @param {number} [now=Date.now()] - Reference timestamp
* @returns {number} Milliseconds
*/
const parseTime = (input, now = Date.now()) => {
if (input == null) return 0;
if (typeof input === 'number') {
return input > 31536000000 ? Math.max(0, input - now) : Math.abs(input);
}
if (input instanceof Date) {
return Math.max(0, input.getTime() - now);
}
if (typeof input === 'object') {
const {days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0} = input;
return Math.abs(days * MS_IN_DAY + hours * MS_IN_HOUR + minutes * MS_IN_MIN + seconds * MS_IN_SEC + milliseconds);
}
if (typeof input === 'string') {
let match;
if (PATTERNS.TIME.test(input)) {
match = input.match(PATTERNS.TIME);
const [, h, m, s, ms] = match;
const hours = h && m ? parseInt(h, 10) : 0;
const minutes = h && m ? parseInt(m, 10) : (h ? parseInt(h, 10) : 0);
const seconds = parseInt(s, 10);
const millis = ms ? parseInt(ms.padEnd(3, '0'), 10) : 0;
return hours * MS_IN_HOUR + minutes * MS_IN_MIN + seconds * MS_IN_SEC + millis;
}
if (match = input.match(PATTERNS.RELATIVE)) {
if (match[1] || match[2] || match[3] || match[4]) {
return (parseInt(match[1] || 0, 10) * MS_IN_DAY +
parseInt(match[2] || 0, 10) * MS_IN_HOUR +
parseInt(match[3] || 0, 10) * MS_IN_MIN +
parseInt(match[4] || 0, 10) * MS_IN_SEC);
}
}
if (PATTERNS.ISO.test(input)) {
const ts = Date.parse(input);
return !isNaN(ts) ? Math.max(0, ts - now) : 0;
}
if (PATTERNS.NUMERIC.test(input)) {
return parseTime(parseInt(input, 10), now);
}
}
return 0;
};
/**
* Convert milliseconds to time components
* @param {number} ms - Milliseconds
* @returns {Object} Time components
*/
const msToComponents = ms => {
const abs = Math.abs(ms);
const days = Math.floor(abs / MS_IN_DAY);
const hours = Math.floor((abs % MS_IN_DAY) / MS_IN_HOUR);
const minutes = Math.floor((abs % MS_IN_HOUR) / MS_IN_MIN);
const seconds = Math.floor((abs % MS_IN_MIN) / MS_IN_SEC);
const milliseconds = Math.floor(abs % MS_IN_SEC);
return {
days,
hours,
minutes,
seconds,
milliseconds,
tenths: Math.floor(milliseconds / 100),
hundredths: Math.floor(milliseconds / 10)
};
};
/**
* Lightweight event emitter using Map
* @private
*/
class EventEmitter {
constructor() {
this._listeners = new Map();
}
on(event, fn) {
const listeners = this._listeners.get(event) || [];
listeners.push(fn);
this._listeners.set(event, listeners);
return () => this.off(event, fn);
}
off(event, fn) {
const listeners = this._listeners.get(event);
if (listeners) {
const idx = listeners.indexOf(fn);
if (idx > -1) listeners.splice(idx, 1);
}
}
emit(event, data) {
const listeners = this._listeners.get(event);
if (listeners) listeners.slice().forEach(fn => fn(data));
}
clear() {
this._listeners.clear();
}
}
/**
* CountdownTimer - Main timer class
* @class
*/
class CountdownTimer {
/**
* @param {Object} [config={}] - Configuration
* @param {string} [config.selector] - DOM selector
* @param {string} [config.mode='countdown'] - 'countdown' or 'countup'
* @param {*} [config.start='00:10'] - Start time
* @param {*} [config.target='00:00'] - Target time
* @param {number} [config.duration] - Duration in seconds
* @param {string} [config.precision='seconds'] - Time precision
* @param {boolean} [config.driftCompensation=true] - Enable drift compensation
* @param {number} [config.offset=0] - Start offset in ms
* @param {boolean} [config.autoStart=false] - Auto-start
* @param {boolean} [config.benchmark=false] - Enable benchmarking
* @param {boolean} [config.debug=false] - Debug mode
*/
constructor(config = {}) {
this.config = {
selector: null,
mode: 'countdown',
start: '00:10',
target: '00:00',
duration: null,
precision: 'seconds',
driftCompensation: true,
offset: 0,
autoStart: true,
benchmark: false,
debug: false,
onStart: null,
onPause: null,
onResume: null,
onStop: null,
onReset: null,
onComplete: null,
onTick: null,
...config
};
// Backward-compatible alias: effect → style
if (this.config.effect && !this.config.style) {
this.config.style = this.config.effect;
}
if (!PRECISION_MS[this.config.precision]) {
this.config.precision = 'seconds';
}
this._events = new EventEmitter();
this._element = null;
this._benchmarkData = null;
this._styleRenderer = null;
this._resetState();
if (this.config.selector) {
this._element = document.querySelector(this.config.selector);
}
this._parseConfig();
if (this.config.benchmark) {
this._benchmarkData = {ticks: 0, totalDrift: 0, maxDrift: 0};
}
if (this.config.autoStart) {
this.start();
}
}
_resetState() {
this.state = {
running: false,
paused: false,
startTime: 0,
pauseTime: 0,
targetMs: 0,
timerId: null,
nextTick: 0
};
}
_parseConfig() {
const {mode, start, target, duration} = this.config;
if (mode === 'countdown') {
this.state.targetMs = duration !== null ? duration * MS_IN_SEC : parseTime(start);
} else {
if (target && target !== '00:00') {
this.state.targetMs = parseTime(target);
} else if (duration !== null) {
this.state.targetMs = duration * MS_IN_SEC;
}
}
if (this.config.offset) {
this.state.targetMs += this.config.offset;
}
}
start(newConfig) {
if (this.state.running) return this;
if (newConfig) {
Object.assign(this.config, newConfig);
this._parseConfig();
}
if (this.config.selector && !this._element) {
this._element = document.querySelector(this.config.selector);
}
const now = Date.now();
const interval = PRECISION_MS[this.config.precision];
if (this.state.paused) {
const pauseDuration = now - this.state.pauseTime;
this.state.startTime += pauseDuration;
this.state.nextTick += pauseDuration;
} else {
const aligned = now - (now % interval);
this.state.startTime = aligned;
this.state.nextTick = aligned;
}
this.state.running = true;
this.state.paused = false;
this._tick();
if (this.config.driftCompensation) {
this._scheduleTick(interval);
} else {
this.state.timerId = setInterval(() => this._tick(), interval);
}
this._emit('start', this.getTime());
if (this.config.onStart) this.config.onStart(this.getTime(), this);
return this;
}
_scheduleTick(interval) {
const schedule = () => {
if (!this.state.running) return;
const now = Date.now();
const drift = now - this.state.nextTick;
if (this._benchmarkData) {
this._benchmarkData.totalDrift += Math.abs(drift);
this._benchmarkData.maxDrift = Math.max(this._benchmarkData.maxDrift, Math.abs(drift));
this._benchmarkData.ticks++;
}
if (drift > interval) {
const missed = Math.floor(drift / interval);
for (let i = 0; i < missed; i++) this._tick();
this.state.nextTick += (missed + 1) * interval;
} else {
this._tick();
this.state.nextTick += interval;
}
this.state.timerId = setTimeout(schedule, Math.max(0, this.state.nextTick - Date.now()));
};
this.state.timerId = setTimeout(schedule, Math.max(0, this.state.nextTick - Date.now()));
}
_tick() {
if (this.config.selector && !this._element) {
this._element = document.querySelector(this.config.selector);
}
const elapsed = this.getElapsed();
const effectiveMs = this.config.mode === 'countdown'
? Math.max(0, this.state.targetMs - elapsed)
: elapsed;
if (this._checkCompletion(effectiveMs)) return;
const formattedTime = this.formatTime();
const timeComponents = this.getTime();
// Style delegation
if (this.config.style && CountdownTimer._styles && CountdownTimer._styles.has(this.config.style)) {
if (!this._styleRenderer) {
const StyleClass = CountdownTimer._styles.get(this.config.style);
this._styleRenderer = new StyleClass(this, this.config.styleOptions);
}
if (this._element && this._styleRenderer.render) {
this._styleRenderer.render(formattedTime, timeComponents);
}
} else {
// Default text rendering
if (this._element) {
this._element.textContent = formattedTime;
}
}
if (this.config.debug) {
console.log('[CountdownTimer]', formattedTime, timeComponents);
}
this._emit('tick', timeComponents);
if (this.config.onTick) this.config.onTick(timeComponents, this);
}
_checkCompletion(value) {
const shouldComplete = this.config.mode === 'countdown'
? value <= 0 && this.state.running
: this.state.targetMs > 0 && value >= this.state.targetMs && this.state.running;
if (shouldComplete) {
this.stop();
this._emit('complete', this.getTime());
if (this.config.onComplete) this.config.onComplete(this.getTime(), this);
return true;
}
return false;
}
stop() {
if (!this.state.running && !this.state.paused) return this;
this._clearTimer();
this._resetState();
this._emit('stop', this.getTime());
if (this.config.onStop) this.config.onStop(this.getTime(), this);
return this;
}
pause() {
if (!this.state.running || this.state.paused) return this;
this._clearTimer();
this.state.running = false;
this.state.paused = true;
this.state.pauseTime = Date.now();
this._emit('pause', this.getTime());
if (this.config.onPause) this.config.onPause(this.getTime(), this);
return this;
}
resume() {
if (!this.state.paused) return this;
const now = Date.now();
const interval = PRECISION_MS[this.config.precision];
const pauseDuration = now - this.state.pauseTime;
this.state.startTime += pauseDuration;
this.state.nextTick = now + interval;
this.state.running = true;
this.state.paused = false;
if (this.config.driftCompensation) {
this._scheduleTick(interval);
} else {
this.state.timerId = setInterval(() => this._tick(), interval);
}
this._emit('resume', this.getTime());
if (this.config.onResume) this.config.onResume(this.getTime(), this);
return this;
}
reset(newConfig) {
const wasRunning = this.state.running;
this.stop();
if (newConfig) {
Object.assign(this.config, newConfig);
this._parseConfig();
}
this._emit('reset', this.getTime());
if (this.config.onReset) this.config.onReset(this.getTime(), this);
if (wasRunning || this.config.autoStart) {
this.start();
}
return this;
}
formatTime(template) {
if (!template) {
template = CountdownTimer.TEMPLATES[this.config.precision] || 'HH:MM:SS';
}
const components = this.getTime();
if (!components) return '00:00:00';
const values = this._getFormattedValues(components);
return template.replace(CountdownTimer.FORMAT_REGEX, match => values[match] || match);
}
_getFormattedValues(c) {
return {
'DD': pad2(c.days || 0),
'D': String(c.days || 0),
'HH': pad2(c.hours || 0),
'H': String(c.hours || 0),
'MM': pad2(c.minutes || 0),
'M': String(c.minutes || 0),
'SS': pad2(c.seconds || 0),
'S': String(c.seconds || 0),
'mmm': pad3(c.milliseconds || 0),
'mm': pad2(c.hundredths || 0),
'm': String(c.tenths || 0)
};
}
getElapsed() {
if (!this.state.startTime) return 0;
const ref = this.state.paused ? this.state.pauseTime : Date.now();
return Math.max(0, ref - this.state.startTime);
}
getRemaining() {
return this.config.mode === 'countdown' ? Math.max(0, this.state.targetMs - this.getElapsed()) : 0;
}
getTime() {
const ms = this.config.mode === 'countdown' ? this.getRemaining() : this.getElapsed();
return msToComponents(ms);
}
isRunning() {
return this.state.running;
}
isPaused() {
return this.state.paused;
}
on(event, callback) {
return this._events.on(event, callback);
}
off(event, callback) {
this._events.off(event, callback);
return this;
}
getBenchmark() {
if (!this._benchmarkData) return null;
const {ticks, totalDrift, maxDrift} = this._benchmarkData;
return {
ticks,
avgDrift: ticks > 0 ? totalDrift / ticks : 0,
maxDrift,
accuracy: ticks > 0 ? (1 - (totalDrift / ticks / PRECISION_MS[this.config.precision])) * 100 : 100
};
}
_emit(event, data) {
this._events.emit(event, data);
}
_clearTimer() {
if (this.state.timerId) {
this.config.driftCompensation ? clearTimeout(this.state.timerId) : clearInterval(this.state.timerId);
this.state.timerId = null;
}
}
destroy() {
// Clean up style renderer
if (this._styleRenderer && typeof this._styleRenderer.destroy === 'function') {
this._styleRenderer.destroy();
this._styleRenderer = null;
}
this.stop();
this._events.clear();
this.config = null;
this.state = null;
this._events = null;
this._element = null;
this._benchmarkData = null;
}
static async testAccuracy(duration = 60) {
return new Promise(resolve => {
const expectedMs = duration * 1000;
const timer = new CountdownTimer({
mode: 'countup',
precision: 'milliseconds',
benchmark: true,
autoStart: true
});
setTimeout(() => {
timer.stop();
const elapsed = timer.getElapsed();
const drift = Math.abs(elapsed - expectedMs);
resolve({
expected: expectedMs,
actual: elapsed,
drift,
accuracy: (100 - drift / expectedMs * 100).toFixed(4) + '%',
benchmark: timer.getBenchmark()
});
}, expectedMs);
});
}
}
// Static properties
CountdownTimer.FORMAT_REGEX = /DD|D|HH|H|MM|M|SS|S|mmm|mm|m/g;
CountdownTimer.TEMPLATES = {
'milliseconds': 'HH:MM:SS.mmm',
'hundredths': 'HH:MM:SS.mm',
'tenths': 'HH:MM:SS.m',
'seconds': 'HH:MM:SS',
'minutes': 'HH:MM'
};
// Style registry
CountdownTimer._styles = new Map();
/**
* Register a custom style renderer
* @param {string} id - Style identifier
* @param {Function} factory - Style class constructor
*/
CountdownTimer.registerStyle = function(id, factory) {
if (id && typeof factory === 'function') {
CountdownTimer._styles.set(id, factory);
}
};
// Convenience alias for backward compatibility
CountdownTimer.registerEffect = CountdownTimer.registerStyle;
return CountdownTimer;
}));
