From f9e2615d11aca126a3bb0d53a37739ee267b2734 Mon Sep 17 00:00:00 2001 From: David Ali Date: Tue, 6 Jan 2026 00:01:23 +0100 Subject: [PATCH 1/2] vibe refactor --- src/wc-timer.js | 573 ++++++++++++++++++++++-------------------------- 1 file changed, 259 insertions(+), 314 deletions(-) diff --git a/src/wc-timer.js b/src/wc-timer.js index c9a3c30..faf20e5 100644 --- a/src/wc-timer.js +++ b/src/wc-timer.js @@ -1,94 +1,69 @@ /** - * 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. + * wc-timer.js — Web Component (Refactored) */ -/* ---------------- 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. +/* ---------------- Memoizacja Intl ---------------- + * Obiekty Intl są kosztowne. Tworzymy je raz i używamy wielokrotnie. + * Kluczem cache jest locale i zserializowane opcje. */ +const IntlCache = (() => { + const cache = new Map(); + + function get(Ctor, locale, opts = {}) { + // Tworzymy unikalny klucz dla kombinacji konstruktora, języka i opcji. + const key = `${Ctor.name}:${locale}:${JSON.stringify(opts)}`; + if (!cache.has(key)) { + cache.set(key, new Ctor(locale, opts)); + } + return cache.get(key); + } + + return { + dtf: (loc, opts) => get(Intl.DateTimeFormat, loc, opts), + pr: (loc) => get(Intl.PluralRules, loc), + df: (loc, opts) => get(Intl.DurationFormat, loc, opts), + // Sprawdzamy wsparcie tylko raz + hasIDF: typeof Intl !== 'undefined' && 'DurationFormat' in Intl + }; +})(); + +/* ---------------- Global synced tick ---------------- */ const TickHub = (() => { const subs = new Set(); let timer = null; function scheduleNext() { - // Align next tick to the next full second. + if (subs.size === 0) return; const now = performance.now(); + // Wyrównanie do pełnej sekundy 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 { } }); + // Kopiujemy Set, aby uniknąć problemów przy dodawaniu/usuwaniu w trakcie pętli + for (const cb of [...subs]) { + try { cb(t); } catch (e) { console.error(e); } + } scheduleNext(); }, msToNext); } return { - /** - * sub(cb): subscribe to ticks. - * Returns an unsubscribe function. - */ sub(cb) { - if (subs.size === 0) scheduleNext(); subs.add(cb); + if (!timer) scheduleNext(); return () => { subs.delete(cb); - if (!subs.size && timer) { clearTimeout(timer); timer = null; } + if (subs.size === 0 && 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. +/* ---------------- Locale & Helpers ---------------- */ const LABELS = { en: { y: { long: { one: 'year', other: 'years' }, short: 'y' }, @@ -107,348 +82,318 @@ const LABELS = { 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) { +function resolveLocale(el) { + const attr = el.getAttribute('locale') || el.getAttribute('lang'); + if (attr) return attr.toLowerCase(); + return (navigator.language || 'en').toLowerCase(); +} + +/* ---------------- Algorytmy dat ---------------- */ + +// Obliczanie różnicy kalendarzowej bez pętli (O(1)) +function diffCalendar(a, b, wantY, wantM) { let anchor = new Date(a); - let y = 0, M = 0; + let y = 0; + let 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++; } + y = b.getFullYear() - anchor.getFullYear(); + // Sprawdzamy, czy "przeskoczyliśmy" datę końcową + const probe = new Date(anchor); + probe.setFullYear(anchor.getFullYear() + y); + if (probe > b) y--; + anchor.setFullYear(anchor.getFullYear() + 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++; } + // Różnica w miesiącach całkowitych + M = (b.getFullYear() - anchor.getFullYear()) * 12 + (b.getMonth() - anchor.getMonth()); + const probe = new Date(anchor); + probe.setMonth(anchor.getMonth() + M); + if (probe > b) M--; + anchor.setMonth(anchor.getMonth() + 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 { y, M, anchor } = diffCalendar(start, end, !!show.y, !!show.M); + + // Jeśli liczyliśmy Y/M, resztę liczymy od 'anchor'. Jeśli nie, od '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; + const MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 }; + const res = { years: y, months: M, days: 0, hours: 0, minutes: 0, seconds: 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; } + if (show.d) { res.days = Math.floor(rest / MS.d); rest %= MS.d; } + if (show.h) { res.hours = Math.floor(rest / MS.h); rest %= MS.h; } + if (show.m) { res.minutes = Math.floor(rest / MS.m); rest %= MS.m; } + if (show.s) { res.seconds = Math.floor(rest / MS.s); } - return { years: y, months: M, days: d, hours: h, minutes: m, seconds: s }; + return res; } -/* ---------------- 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(':'); -} +/* ---------------- Formatter ---------------- */ +const pad2 = (n) => String(n ?? 0).padStart(2, '0'); 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 + // Tryb: none (czysty tekst z dwukropkami) 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 ? ' ' : ''); + if (show.y) head.push(parts.years); + if (show.M) head.push(parts.months); + if (show.d) head.push(parts.days); + + const hms = [pad2(parts.hours), pad2(parts.minutes), pad2(parts.seconds)]; + if (!show.h) hms.shift(); // jeśli ukryte godziny, usuń + + return [...head, hms.join(':')].join(' '); } - // Prefer native DurationFormat, it handles pluralization and spacing. - if (hasIDF) { - const style = labelsMode === 'short' ? 'short' : 'long'; - const opts = { style }; + // Tryb: Intl.DurationFormat (jeśli dostępny) + if (IntlCache.hasIDF) { + const opts = { style: labelsMode === 'short' ? 'short' : 'long' }; 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); + // Przekazujemy tylko niezerowe/wymagane jednostki 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); + for (const key in parts) { + // Mapowanie kluczy (years -> years) jest 1:1 w tym przypadku + if (show[key.slice(0, 1)] || show[key.slice(0, 2)]) inParts[key] = parts[key]; + } + return IntlCache.df(locale, opts).format(inParts); } - // Fallback: plural rules + our dictionaries - const PR = new Intl.PluralRules(locale); + // Tryb: Fallback (słownikowy) + const lang2 = LABELS[locale.slice(0, 2)] ? locale.slice(0, 2) : 'en'; const L = LABELS[lang2]; + const pr = IntlCache.pr(locale); + const out = []; - 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}`); + const add = (k, v, forcePad = false) => { + const cat = pr.select(v); + const word = L[k].long[cat] ?? L[k].long.other; + const num = forcePad ? pad2(v) : v; + const suffix = labelsMode === 'short' ? L[k].short : word; + out.push(`${num} ${suffix}`); }; - 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); + if (show.y) add('y', parts.years); + if (show.M) add('M', parts.months); + if (show.d) add('d', parts.days); + if (show.h) add('h', parts.hours, true); + if (show.m) add('m', parts.minutes, true); + if (show.s) add('s', parts.seconds, true); - return [head.join(' '), hms.join(' ')].filter(Boolean).join(head.length && hms.length ? ' ' : ''); + return out.join(' '); } -/* ---------------- 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 }; +function getShowConfig(preset, diffMs) { + const base = { y: false, M: false, d: true, h: true, m: true, s: true }; + + switch (preset) { + case 'hms': return { ...base, d: false }; + case 'dhms': return base; + case 'ydhms': return { y: true, M: true, d: true, h: true, m: true, s: true }; + case 'auto': + // Jeśli więcej niż 30 dni, pokaż dni. + return diffMs >= 2592000000 ? base : { ...base, d: false }; + default: return base; } - 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. - */ +/* ---------------- Web Component ---------------- */ +// Style współdzielone (wydajność pamięci) +const sheet = new CSSStyleSheet(); +sheet.replaceSync(` + :host { + display:inline-block; + color: inherit; + user-select: text; -webkit-user-select: text; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +`); + class WcTimer extends HTMLElement { static get observedAttributes() { return ['target', 'preset', 'labels', 'locale', 'lang', 'compact']; } + + // Pola prywatne + #target = new Date(); + #preset = 'auto'; + #labels = 'short'; + #compact = false; + #locale = 'en'; + #unsub = null; + #textNode = null; + #isAttrUpdate = false; 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'; + const shadow = this.attachShadow({ mode: 'open' }); + shadow.adoptedStyleSheets = [sheet]; + shadow.appendChild(document.createElement('slot')); } 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); - } + // Inicjalizacja węzła tekstowego w Light DOM + if (!this.firstChild || this.firstChild.nodeType !== Node.TEXT_NODE) { + this.textContent = ''; + this.#textNode = document.createTextNode(''); + this.appendChild(this.#textNode); + } else { + this.#textNode = this.firstChild; } - this._syncFromAttrs(); - this._renderOnce(Date.now()); - this._unsub = TickHub.sub(t => this._renderOnce(t)); + this.#syncAttrs(); + this.#startTicking(); } disconnectedCallback() { - if (this._unsub) { this._unsub(); this._unsub = null; } + this.#stopTicking(); } attributeChangedCallback() { - // Ignore updates triggered by our own setters. - if (this._updatingAttr) return; - this._syncFromAttrs(); - this._renderOnce(Date.now()); + if (this.#isAttrUpdate) return; + this.#syncAttrs(); + // Renderujemy natychmiast po zmianie atrybutu + this.#render(Date.now()); + // Jeśli zmieniono cel na przyszły, upewnij się, że zegar tyka + this.#checkRestart(); } - // 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; + #syncAttrs() { + const tRaw = this.getAttribute('target'); + this.#target = tRaw ? new Date(tRaw) : new Date(); + if (isNaN(this.#target)) this.#target = new Date(); - 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); + this.#preset = (this.getAttribute('preset') || 'auto').toLowerCase(); + this.#labels = (this.getAttribute('labels') || 'short').toLowerCase(); + this.#compact = this.hasAttribute('compact'); + this.#locale = resolveLocale(this); } - // Compute and render the string once. - _renderOnce(nowMs) { - if (!this._text) return; + #startTicking() { + if (!this.#unsub) { + this.#unsub = TickHub.sub((now) => this.#render(now)); + this.#render(Date.now()); + } + } - // Date-only preset: defer to Intl.DateTimeFormat. - if (this._preset === 'date') { - const dtf = new Intl.DateTimeFormat(this._locale, { + #stopTicking() { + if (this.#unsub) { + this.#unsub(); + this.#unsub = null; + } + } + + #checkRestart() { + // Jeśli mamy przyszły target, a zegar stoi -> wznów go + const now = Date.now(); + if (this.#target > now && !this.#unsub) { + this.#startTicking(); + } + } + + #render(nowMs) { + if (!this.#textNode) return; + + // Tryb daty statycznej + if (this.#preset === 'date') { + const dtf = IntlCache.dtf(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; + const txt = dtf.format(this.#target); + if (this.#textNode.data !== txt) this.#textNode.data = txt; 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; + const diff = this.#target - now; + const isFuture = diff >= 0; + + // Auto-stop, jeśli minęliśmy czas + if (!isFuture && this.#unsub) { + this.#stopTicking(); + } - // Decide what to show based on preset. - let show = unitsForPreset(this._preset, start, end); + const start = isFuture ? now : this.#target; + const end = isFuture ? this.#target : now; - // compact: hide empty high units (Y/M/D), and 0 hours when days are shown. - if (this._compact) { + let show = getShowConfig(this.#preset, Math.abs(diff)); + + // Logika compact (ukrywanie zerowych jednostek wysokiego rzędu) + 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 }; + if (show.y && probe.years === 0) show.y = false; + if (show.M && !show.y && probe.months === 0) show.M = false; + if (show.d && !show.y && !show.M && probe.days === 0) show.d = false; + // Przelicz ponownie po redukcji jednostek const probe2 = collapseForUnits(start, end, show); - if (show.d && show.h && probe2.hours === 0) show = { ...show, h: false }; + if (show.d && show.h && probe2.hours === 0) 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; + const txt = formatDurationSmart(this.#locale, this.#labels, show, parts); + + if (this.#textNode.data !== txt) this.#textNode.data = txt; } - /* ======================= - * 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()); + /* Public API */ + get target() { return new Date(this.#target); } + set target(val) { + const d = new Date(val); + if (isNaN(d)) return; + this.#target = d; + this.#reflect('target', d.toISOString()); + this.#render(Date.now()); + this.#checkRestart(); } - // 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()); + get preset() { return this.#preset; } + set preset(val) { + this.#preset = String(val).toLowerCase(); + this.#reflect('preset', this.#preset); + this.#render(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()); + get labels() { return this.#labels; } + set labels(val) { + this.#labels = String(val).toLowerCase(); + this.#reflect('labels', this.#labels); + this.#render(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()); + get compact() { return this.#compact; } + set compact(val) { + this.#compact = !!val; + if (this.#compact) this.setAttribute('compact', ''); + else this.removeAttribute('compact'); + this.#isAttrUpdate = true; // Flaga dla AttributeChanged + this.#render(Date.now()); + this.#isAttrUpdate = false; } - // 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()); + set locale(val) { + if (!val) return; + this.#locale = String(val).toLowerCase(); + this.#reflect('locale', this.#locale); + this.#render(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; + #reflect(name, val) { + this.#isAttrUpdate = true; + this.setAttribute(name, val); + this.#isAttrUpdate = false; } } -customElements.define('wc-timer', WcTimer); +customElements.define('wc-timer', WcTimer); \ No newline at end of file From 2451b1a9e796df68f69a88f7c3da9b068fed8633 Mon Sep 17 00:00:00 2001 From: David Ali Date: Tue, 6 Jan 2026 01:50:20 +0100 Subject: [PATCH 2/2] version 1.1.0 --- .gitignore | 3 +- .npmignore | 13 ------ CHANGELOG.md | 23 ++++++++++ Dockerfile | 1 + Jenkinsfile | 6 +-- README.md | 78 +++++++++++++++++++++++---------- package.json | 3 +- src/wc-timer.js | 114 +++++++++++++++++++++++++++++------------------- 8 files changed, 153 insertions(+), 88 deletions(-) delete mode 100644 .npmignore create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index 790865c..a852954 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -e2e/cypress/screenshots/* \ No newline at end of file +e2e/cypress/screenshots/* +*.old diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 5811047..0000000 --- a/.npmignore +++ /dev/null @@ -1,13 +0,0 @@ -# Ignoruj narzędzia CI/CD -Jenkinsfile -Dockerfile -docker-compose.test.yml -.git -.gitignore - -# Ignoruj testy i demo -e2e/ -public/ - -# Ignoruj konfigurację IDE -.vscode/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..476b6cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# CHANGELOG + +## [1.1.0] : Refactor and Optimization + +### Performance +* **IntlCache**: Implemented caching for `Intl` objects. Date formatting no longer creates new instances on every refresh. +* **Shared Styles**: Used `adoptedStyleSheets` instead of `