Files
wc-timer/src/wc-timer.js
2026-01-05 18:40:48 +01:00

455 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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):
* <script type="module" src="/assets/wc-timer.js"></script>
* <wc-timer target="2025-12-31T23:59:59Z"></wc-timer>
* <wc-timer preset="dhms" labels="long" compact target="..."></wc-timer>
*
* 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:
* - Dont 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] -> <html 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 <wc-timer>.
* 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);