/** * wc-timer.js — Web Component * --------------------------------------------- * A tiny countdown/uptimer that renders as plain text * (light DOM). Text is selectable and styles with normal CSS. * * Features: * - Presets: auto | hms | dhms | ydhms | date * - Auto locale (with override via locale/lang attrib) * - Uses Intl.DurationFormat when available (fallback included) * - “Compact” mode hides empty high units (e.g., 0 years) * - One global, synced tick for all instances (no drift, low CPU) * - Public JS API (properties): target, preset, labels, compact, locale * * Usage (HTML): * * * * * Styling: * wc-timer { font-size: 20px; color: #4da3ff; } * * JS control: * const t = document.querySelector('wc-timer'); * t.target = '2025-10-12T00:30:00Z'; // string | Date | ms number * t.preset = 'dhms'; // 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date' * t.labels = 'short'; // 'none' | 'short' | 'long' * t.compact = true; // boolean * t.locale = 'pl'; // locale override * * Notes: * - Don’t mutate private fields (those starting with “_”). * Use public setters (e.g., el.target = ...). * - “auto” shows HH:MM:SS for near targets and adds days for far targets. * - If DurationFormat is missing, we use a small fallback with plural rules. */ /* ---------------- Global synced tick ---------------- * One scheduler for all instances. It fires once per second, * aligned to the next real second boundary. * Saves CPU and avoids per-element setInterval drift. */ const TickHub = (() => { const subs = new Set(); let timer = null; function scheduleNext() { // Align next tick to the next full second. const now = performance.now(); const msToNext = 1000 - (Math.floor(now) % 1000); timer = setTimeout(() => { const t = Date.now(); // Notify all subscribers. Errors are isolated. subs.forEach(cb => { try { cb(t); } catch { } }); scheduleNext(); }, msToNext); } return { /** * sub(cb): subscribe to ticks. * Returns an unsubscribe function. */ sub(cb) { if (subs.size === 0) scheduleNext(); subs.add(cb); return () => { subs.delete(cb); if (!subs.size && timer) { clearTimeout(timer); timer = null; } }; } }; })(); /* ---------------- Locale helpers ---------------- * We prefer: explicit element attribute -> nearest [lang] -> * -> navigator.languages[0] -> navigator.language -> 'en' */ const hasIDF = typeof Intl !== 'undefined' && 'DurationFormat' in Intl; function resolveLocale(el) { const attr = el.getAttribute('locale') || el.getAttribute('lang'); const tree = el.closest?.('[lang]')?.getAttribute('lang') || document.documentElement.lang; const nav = Array.isArray(navigator.languages) && navigator.languages.length ? navigator.languages[0] : navigator.language; return (attr || tree || nav || 'en').toLowerCase(); } // Fallback label dictionaries (English + Polish). // Used only when Intl.DurationFormat is not available. const LABELS = { en: { y: { long: { one: 'year', other: 'years' }, short: 'y' }, M: { long: { one: 'month', other: 'months' }, short: 'mo' }, d: { long: { one: 'day', other: 'days' }, short: 'd' }, h: { long: { one: 'hour', other: 'hours' }, short: 'h' }, m: { long: { one: 'minute', other: 'minutes' }, short: 'min' }, s: { long: { one: 'second', other: 'seconds' }, short: 's' }, }, pl: { y: { long: { one: 'rok', few: 'lata', many: 'lat', other: 'lat' }, short: 'r.' }, M: { long: { one: 'miesiąc', few: 'miesiące', many: 'miesięcy', other: 'miesięcy' }, short: 'mies.' }, d: { long: { one: 'dzień', few: 'dni', many: 'dni', other: 'dni' }, short: 'd' }, h: { long: { one: 'godzina', few: 'godziny', many: 'godzin', other: 'godzin' }, short: 'godz.' }, m: { long: { one: 'minuta', few: 'minuty', many: 'minut', other: 'minut' }, short: 'min' }, s: { long: { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekund' }, short: 's' }, } }; const clampLocale = loc => (LABELS[loc.slice(0, 2)] ? loc.slice(0, 2) : 'en'); /* ---------------- Diff with smart collapsing ---------------- * We compute calendar years/months only if those units are shown. * If not shown, their time “falls down” into lower units (days/hours/...). */ function extractYearsMonthsCalendar(a, b, wantY, wantM) { let anchor = new Date(a); let y = 0, M = 0; // Add full years while not passing end. if (wantY) { while (new Date( anchor.getFullYear() + 1, anchor.getMonth(), anchor.getDate(), anchor.getHours(), anchor.getMinutes(), anchor.getSeconds() ) <= b) { anchor.setFullYear(anchor.getFullYear() + 1); y++; } } // Then add full months while not passing end. if (wantM) { while (new Date( anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate(), anchor.getHours(), anchor.getMinutes(), anchor.getSeconds() ) <= b) { anchor.setMonth(anchor.getMonth() + 1); M++; } } return { y, M, anchor }; } function collapseForUnits(start, end, show) { const { y, M, anchor } = extractYearsMonthsCalendar(start, end, !!show.y, !!show.M); // If we showed Y/M, base the remaining ms on the anchor (after adding Y/M). // Otherwise, consume everything from the start. const base = (show.y || show.M) ? anchor : start; let rest = Math.max(0, end - base); // Greedy consume into D/H/m/s only for units we plan to display. const dayMs = 86400000, hourMs = 3600000, minMs = 60000, secMs = 1000; let d = 0, h = 0, m = 0, s = 0; if (show.d) { d = Math.floor(rest / dayMs); rest -= d * dayMs; } if (show.h) { h = Math.floor(rest / hourMs); rest -= h * hourMs; } if (show.m) { m = Math.floor(rest / minMs); rest -= m * minMs; } if (show.s) { s = Math.floor(rest / secMs); rest -= s * secMs; } return { years: y, months: M, days: d, hours: h, minutes: m, seconds: s }; } /* ---------------- Formatters ---------------- * pad2: always pad for H/M/S “digital” look. * formatHMS: joins visible HMS with ':'. * formatDurationSmart: uses DurationFormat if available; otherwise fallback. */ function pad2(n) { return String(n ?? 0).padStart(2, '0'); } function formatHMS(show, p) { const segs = []; if (show.h) segs.push(pad2(p.hours)); if (show.m) segs.push(pad2(p.minutes)); if (show.s) segs.push(pad2(p.seconds)); return segs.join(':'); } function formatDurationSmart(locale, labelsMode, show, parts) { const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en'; // labels="none" → head units as numbers, HMS as HH:MM:SS if (labelsMode === 'none') { const head = []; if (show.y) head.push(String(parts.years)); if (show.M) head.push(String(parts.months)); if (show.d) head.push(String(parts.days)); const tail = formatHMS(show, parts); return [head.join(' '), tail].filter(Boolean).join(head.length && tail ? ' ' : ''); } // Prefer native DurationFormat, it handles pluralization and spacing. if (hasIDF) { const style = labelsMode === 'short' ? 'short' : 'long'; const opts = { style }; if (show.h) opts.hours = '2-digit'; if (show.m) opts.minutes = '2-digit'; if (show.s) opts.seconds = '2-digit'; const df = new Intl.DurationFormat(locale, opts); const inParts = {}; if (show.y) inParts.years = parts.years; if (show.M) inParts.months = parts.months; if (show.d) inParts.days = parts.days; if (show.h) inParts.hours = parts.hours; if (show.m) inParts.minutes = parts.minutes; if (show.s) inParts.seconds = parts.seconds; return df.format(inParts); } // Fallback: plural rules + our dictionaries const PR = new Intl.PluralRules(locale); const L = LABELS[lang2]; const head = []; const push = (key, val) => { const cat = PR.select(val); const word = L[key].long[cat] ?? L[key].long.other; head.push(labelsMode === 'short' ? `${val} ${L[key].short}` : `${val} ${word}`); }; if (show.y) push('y', parts.years); if (show.M) push('M', parts.months); if (show.d) push('d', parts.days); const hms = []; const pushH = (key, val) => { const cat = PR.select(val); const word = L[key].long[cat] ?? L[key].long.other; if (labelsMode === 'short') hms.push(`${pad2(val)} ${L[key].short}`); else hms.push(`${pad2(val)} ${word}`); }; if (show.h) pushH('h', parts.hours); if (show.m) pushH('m', parts.minutes); if (show.s) pushH('s', parts.seconds); return [head.join(' '), hms.join(' ')].filter(Boolean).join(head.length && hms.length ? ' ' : ''); } /* ---------------- Presets ---------------- * hms → HH:MM:SS * dhms → D + HH:MM:SS * ydhms → Y + M + D + HH:MM:SS (calendar-aware) * auto → HH:MM:SS when near; D + HH:MM:SS when far (>= 30 days) * default → dhms */ function unitsForPreset(preset, start, end) { if (preset === 'hms') return { y: false, M: false, d: false, h: true, m: true, s: true }; if (preset === 'dhms') return { y: false, M: false, d: true, h: true, m: true, s: true }; if (preset === 'ydhms') return { y: true, M: true, d: true, h: true, m: true, s: true }; if (preset === 'auto') { const diff = Math.max(0, end - start); const dayMs = 86400000; return diff >= 30 * dayMs ? { y: false, M: false, d: true, h: true, m: true, s: true } : { y: false, M: false, d: false, h: true, m: true, s: true }; } return { y: false, M: false, d: true, h: true, m: true, s: true }; } /* ---------------- Web Component ---------------- * Light-DOM: visible text is a single Text node inside . * We never replace that node; we only update its .data → selection is stable. */ class WcTimer extends HTMLElement { static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; } constructor() { super(); this.attachShadow({ mode: 'open' }); // Shadow only defines style and a default slot. // Text is in light DOM (selectable, styleable). const style = document.createElement('style'); style.textContent = ` :host { display:inline-block; color: inherit; user-select: text; -webkit-user-select: text; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-variant-numeric: tabular-nums; white-space: nowrap; } ::slotted(*) { all: unset; } `; const slot = document.createElement('slot'); this.shadowRoot.append(style, slot); // Single managed text node in light DOM. this._text = null; // Internal state. this._unsub = null; this._updatingAttr = false; // guard against attribute echo from setters this._target = new Date(); this._preset = 'auto'; this._labels = 'short'; // 'none' | 'short' | 'long' this._compact = false; this._locale = 'en'; } connectedCallback() { // Ensure the first child is our Text node. if (!this._text) { if (this.firstChild && this.firstChild.nodeType === Node.TEXT_NODE) { this._text = this.firstChild; } else { this.textContent = ''; this._text = document.createTextNode(''); this.appendChild(this._text); } } this._syncFromAttrs(); this._renderOnce(Date.now()); this._unsub = TickHub.sub(t => this._renderOnce(t)); } disconnectedCallback() { if (this._unsub) { this._unsub(); this._unsub = null; } } attributeChangedCallback() { // Ignore updates triggered by our own setters. if (this._updatingAttr) return; this._syncFromAttrs(); this._renderOnce(Date.now()); } // Pull current attributes into state. _syncFromAttrs() { const raw = this.getAttribute('target'); const t = raw ? new Date(raw) : new Date(); this._target = Number.isNaN(t.getTime()) ? new Date() : t; this._preset = (this.getAttribute('preset') || 'auto').toLowerCase(); // 'auto'|'hms'|'dhms'|'ydhms'|'date' this._labels = (this.getAttribute('labels') || 'short').toLowerCase(); // 'none'|'short'|'long' this._compact = this.hasAttribute('compact'); this._locale = resolveLocale(this); } // Compute and render the string once. _renderOnce(nowMs) { if (!this._text) return; // Date-only preset: defer to Intl.DateTimeFormat. if (this._preset === 'date') { const dtf = new Intl.DateTimeFormat(this._locale, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const out = dtf.format(this._target); if (this._text.data !== out) this._text.data = out; return; } // Build the time window depending on future/past. const now = new Date(nowMs); const future = this._target - now >= 0; const start = future ? now : this._target; const end = future ? this._target : now; // Decide what to show based on preset. let show = unitsForPreset(this._preset, start, end); // compact: hide empty high units (Y/M/D), and 0 hours when days are shown. if (this._compact) { const probe = collapseForUnits(start, end, show); if (show.y && probe.years === 0) show = { ...show, y: false }; if (show.M && !show.y && probe.months === 0) show = { ...show, M: false }; if (show.d && !show.y && !show.M && probe.days === 0) show = { ...show, d: false }; const probe2 = collapseForUnits(start, end, show); if (show.d && show.h && probe2.hours === 0) show = { ...show, h: false }; } const parts = collapseForUnits(start, end, show); // If countdown reached zero, stop ticking to save CPU. if (future && end - now <= 0 && this._unsub) { this._unsub(); this._unsub = null; } // Format the output string and update only TextNode.data. const out = formatDurationSmart(this._locale, this._labels, show, parts); if (this._text.data !== out) this._text.data = out; } /* ======================= * Public API (properties) * ======================= * Use these instead of touching private fields. */ // target: Date | string | number(ms) get target() { return this._target; } set target(v) { const d = this._coerceDate(v); if (!d) return; this._target = d; this._updatingAttr = true; // prevent attributeChanged echo this.setAttribute('target', d.toISOString()); this._updatingAttr = false; this._renderOnce(Date.now()); } // preset: 'auto' | 'hms' | 'dhms' | 'ydhms' | 'date' get preset() { return this._preset; } set preset(v) { const p = String(v || '').toLowerCase(); this._preset = p; this._updatingAttr = true; this.setAttribute('preset', p); this._updatingAttr = false; this._renderOnce(Date.now()); } // labels: 'none' | 'short' | 'long' get labels() { return this._labels; } set labels(v) { const l = String(v || '').toLowerCase(); this._labels = l; this._updatingAttr = true; this.setAttribute('labels', l); this._updatingAttr = false; this._renderOnce(Date.now()); } // compact: boolean get compact() { return this._compact; } set compact(v) { const on = !!v; this._compact = on; this._updatingAttr = true; this.toggleAttribute('compact', on); this._updatingAttr = false; this._renderOnce(Date.now()); } // locale: IETF BCP 47 (e.g., 'pl', 'en-GB'); overrides auto detection get locale() { return this._locale; } set locale(v) { const loc = (v || '').toString(); if (!loc) return; this._locale = loc.toLowerCase(); this._updatingAttr = true; this.setAttribute('locale', this._locale); this._updatingAttr = false; this._renderOnce(Date.now()); } // --- helpers --- _coerceDate(v) { if (v instanceof Date) return isNaN(v.getTime()) ? null : v; if (typeof v === 'number') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; } if (typeof v === 'string') { const d = new Date(v); return isNaN(d.getTime()) ? null : d; } return null; } } customElements.define('wc-timer', WcTimer);