class RelativeTimeElement extends HTMLElement { static get observedAttributes() { return ['datetime', 'threshold', 'prefix', 'format']; } connectedCallback() { this.update(); } disconnectedCallback() { this.stopTimer(); } attributeChangedCallback() { this.update(); } scheduleUpdate(ms) { this.stopTimer(); this.timer = setTimeout(() => this.update(), ms); } stopTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } get datetime() { return this.getAttribute('datetime') || ''; } get threshold() { return this.getAttribute('threshold') || 'P14D'; } get prefix() { return this.getAttribute('prefix') || 'on'; } get format() { return this.getAttribute('format') || 'relative'; } parseThreshold(iso) { const match = iso.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); if (!match) return 30 * 24 * 60 * 60 * 1000; const days = parseInt(match[1] || 0, 10); const hours = parseInt(match[2] || 0, 10); const minutes = parseInt(match[3] || 0, 10); const seconds = parseInt(match[4] || 0, 10); return ((days * 24 + hours) * 60 + minutes) * 60 * 1000 + seconds * 1000; } update() { const datetime = this.datetime; if (!datetime) return; const date = new Date(datetime); if (isNaN(date.getTime())) return; const now = Date.now(); const diff = now - date.getTime(); const absDiff = Math.abs(diff); const thresholdMs = this.parseThreshold(this.threshold); if (this.format === 'datetime' || absDiff > thresholdMs) { this.textContent = this.formatDatetime(date); this.scheduleUpdate(3600000); } else { this.textContent = this.formatRelative(diff); const delay = this.getNextUpdateDelay(absDiff); if (delay !== null) this.scheduleUpdate(delay); } } getNextUpdateDelay(absDiff) { const seconds = Math.floor(absDiff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) { return 1000; } else if (minutes < 60) { return 60000; } else if (hours < 24) { return 60000 * 5; } else if (days < 7) { return 3600000; } else if (days < 30) { return 3600000 * 6; } else { return null; } } getRtf() { if (!this._rtf || this._rtfLang !== navigator.language) { this._rtfLang = navigator.language; this._rtf = new Intl.RelativeTimeFormat(navigator.language, { numeric: 'auto', style: 'long' }); } return this._rtf; } formatRelative(diff) { const rtf = this.getRtf(); const absDiff = Math.abs(diff); const sign = diff > 0 ? -1 : 1; const seconds = Math.floor(absDiff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const date = new Date(Date.now() - diff); const now = new Date(); const months = (now.getFullYear() - date.getFullYear()) * 12 + (now.getMonth() - date.getMonth()); const years = Math.floor(Math.abs(months) / 12) * sign; if (seconds < 60) { return rtf.format(sign * seconds, 'second'); } else if (minutes < 60) { return rtf.format(sign * minutes, 'minute'); } else if (hours < 24) { return rtf.format(sign * hours, 'hour'); } else if (days < 30) { return rtf.format(sign * days, 'day'); } else if (Math.abs(months) < 12) { return rtf.format(months, 'month'); } else { return rtf.format(years, 'year'); } } formatDatetime(date) { const now = new Date(); const sameYear = date.getFullYear() === now.getFullYear(); const options = { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) }; const prefix = this.prefix; const formatted = new Intl.DateTimeFormat(navigator.language, options).format(date); return prefix ? `${prefix} ${formatted}` : formatted; } } customElements.define('relative-time', RelativeTimeElement);