commit 9e03cfa3ae07358b47c818b721dd24519bef0eee Author: David Ali Date: Mon Jan 5 18:40:48 2026 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..790865c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +e2e/cypress/screenshots/* \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..5811047 --- /dev/null +++ b/.npmignore @@ -0,0 +1,13 @@ +# 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d987417 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Użycie lekkiego, stabilnego obrazu Node.js +FROM node:20-alpine + +# Ustawienie katalogu roboczego dla kodu źródłowego i zależności +WORKDIR /app/wc-timer + +# Kopiowanie plików manifestu i instalacja zależności +COPY package.json ./ +RUN npm install + +# Kopiowanie reszty plików projektu (kod źródłowy) +COPY src/wc-timer.js src/wc-timer.js + +# Instalacja prostego serwera HTTP +RUN npm install -g http-server + +# Kopiowanie publicznego katalogu demonstracyjnego do osobnego miejsca +WORKDIR /app/public +COPY public/. . + +# Wracamy do głównego katalogu roboczego, skąd chcemy serwować pliki. +WORKDIR /app + +# Utworzenie linku symbolicznego do pliku JS (jak w konfiguracji Nginx) +# Serwer http-server będzie go poprawnie obsługiwał. +RUN mkdir -p public/assets +RUN ln -sf ../../wc-timer/src/wc-timer.js public/assets/wc-timer.js + +# Uruchomienie serwera WWW, serwującego katalog /app/public na porcie 8000 +# -c-1 wyłącza cache, co jest pomocne w development +CMD ["http-server", "public", "-p", "8000", "-c-1"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..27b3da5 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# wc-timer + +A lightweight, dependency-free Web Component for countdowns and elapsed time. It uses the native `Intl` API for formatting and supports automatic language detection. + +## Features + +* **Zero Dependencies**: Built with standard Web Components API. +* **Smart Formatting**: Supports various presets like `hms`, `dhms`, or `ydhms`. +* **Performance**: Uses a shared global ticker (TickHub) to sync all instances and save CPU. +* **Light DOM**: Renders text directly, making it easy to style with global CSS. +* **Responsive**: Automatically updates when attributes change. + +## Installation + +```bash +npm install wc-timer + +``` + +## Usage + +1. **Import the script**: + +```javascript +import 'wc-timer'; +// Or via script tag +// + +``` + +2. **Use the tag in HTML**: + +```html + + + + + +``` + +## API Reference + +### Attributes + +| Attribute | Type | Default | Description | +| --- | --- | --- | --- | +| `target` | `ISO 8601` | `now` | The target date/time (e.g., `2025-12-31T23:59:59Z`). | +| `preset` | `string` | `auto` | Format preset: `auto`, `hms`, `dhms`, `ydhms`, `date`. | +| `labels` | `string` | `long` | Unit labels: `none` (00:00:00), `short` (1h 5m), `long` (1 hour 5 minutes). | +| `compact` | `boolean` | `false` | If present, hides zero-value units (e.g., skips "0 years"). | +| `locale` | `BCP 47` | `auto` | Forces a specific language (e.g., `pl`, `en`, `de`). Defaults to browser settings. | + +### JavaScript Properties + +All attributes are reflected as properties on the DOM element. + +```javascript +const el = document.querySelector('wc-timer'); + +// Update target dynamically +el.target = new Date('2030-01-01'); + +// Change configuration +el.preset = 'ydhms'; +el.compact = true; + +``` + +## Browser Support + +Works in all modern browsers supporting Web Components and `Intl.DurationFormat` (or falls back gracefully). + +## License + +MIT diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..f0075eb --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,23 @@ +version: '3.8' +services: + # Serwis aplikacji budowany z naszego Dockerfile + app: + build: . + container_name: ntwo-app + ports: + - "8000:8000" + + # Oficjalny kontener Cypress z przegladarkami + cypress: + image: cypress/included:13.6.0 + container_name: ntwo-tests + # Zaleznosc: testy rusza dopiero po starcie aplikacji + depends_on: + - app + environment: + # Adres pod ktorym Cypress widzi nasza aplikacje w sieci Docker + - CYPRESS_baseUrl=http://app:8000 + working_dir: /e2e + # Montowanie wolumenow: pozwala zapisac zrzuty ekranu na dysku hosta + volumes: + - ./e2e:/e2e \ No newline at end of file diff --git a/e2e/cypress.config.js b/e2e/cypress.config.js new file mode 100644 index 0000000..09d44aa --- /dev/null +++ b/e2e/cypress.config.js @@ -0,0 +1,14 @@ +const { defineConfig } = require("cypress"); + +module.exports = defineConfig({ + e2e: { + setupNodeEvents(on, config) { + // miejsce na listenery zdarzen (np. do generowania raportow) + }, + // Domyslny url, nadpisywany przez zmienna srodowiskowa w docker-compose + baseUrl: 'http://localhost:8000', + specPattern: 'cypress/**/*.cy.js', // Dostosowanie sciezki do struktury plikow + supportFile: false, // Wylaczamy pliki wsparcia dla uproszczenia struktury + video: false // Wylaczamy nagrywanie wideo, wystarcza zrzuty ekranu + }, +}); \ No newline at end of file diff --git a/e2e/cypress/wc-timer.cy.js b/e2e/cypress/wc-timer.cy.js new file mode 100644 index 0000000..b05fdb3 --- /dev/null +++ b/e2e/cypress/wc-timer.cy.js @@ -0,0 +1,140 @@ +describe('testy komponentu wc-timer', () => { + // Data początkowa: 1 stycznia 2025, godzina 12:00:00 + const NOW_TIMESTAMP = 1735732800000; + + beforeEach(() => { + // 1. Zamrażamy czas + cy.clock(NOW_TIMESTAMP); + + // 2. Synchronizujemy performance.now() + cy.visit('/', { + onBeforeLoad(win) { + cy.stub(win.performance, 'now').callsFake(() => win.Date.now()); + }, + }); + + // 3. Czekamy na definicję komponentu + cy.window().then((win) => win.customElements.whenDefined('wc-timer')); + }); + + describe('widoczność i inicjalizacja', () => { + it('element jest widoczny w DOM', () => { + cy.get('wc-timer').first().should('be.visible'); + // SCREENSHOT 1: Stan początkowy aplikacji + cy.screenshot('01-init-visible'); + }); + + it('renderuje tekst po załadowaniu', () => { + cy.get('wc-timer').first().invoke('text').should('not.be.empty'); + }); + }); + + describe('atrybuty html (declarative)', () => { + it('obsługuje zmianę atrybutu target i preset', () => { + const targetStr = new Date(NOW_TIMESTAMP + 2 * 3600000).toISOString(); + + cy.get('wc-timer').first() + .invoke('attr', 'labels', 'none') + .invoke('attr', 'target', targetStr) + .invoke('attr', 'preset', 'hms'); + + cy.get('wc-timer').first().should('contain.text', '02:00:00'); + // SCREENSHOT 2: Format HMS bez etykiet + cy.screenshot('02-attr-preset-hms'); + + // Zmiana na dni-godziny-minuty + cy.get('wc-timer').first() + .invoke('attr', 'preset', 'dhms') + .invoke('attr', 'labels', 'short'); + + cy.get('wc-timer').first().invoke('text').should('match', /0\s(d|dni|days)/); + // SCREENSHOT 3: Format DHMS z etykietami + cy.screenshot('03-attr-preset-dhms'); + }); + + it('tryb compact ukrywa puste jednostki', () => { + const targetStr = new Date(NOW_TIMESTAMP + 5 * 60000).toISOString(); + + cy.get('wc-timer').first() + .invoke('attr', 'labels', 'none') + .invoke('attr', 'compact', '') + .invoke('attr', 'target', targetStr); + + cy.get('wc-timer').first().should('contain.text', '05:00'); + // SCREENSHOT 4: Tryb compact (ukryte godziny) + cy.screenshot('04-attr-compact'); + }); + }); + + describe('api javascript (imperative)', () => { + it('aktualizuje widok po ustawieniu właściwości .target w JS', () => { + const targetStr = new Date(NOW_TIMESTAMP + 10000).toISOString(); + + cy.get('wc-timer').first() + .should(($el) => { + expect($el[0]).to.have.property('preset'); + }) + .then(($el) => { + const el = $el[0]; + el.target = targetStr; + el.preset = 'hms'; + el.labels = 'none'; + }); + + cy.get('wc-timer').first().should('contain.text', '00:00:10'); + // SCREENSHOT 5: Zmiana przez JS API + cy.screenshot('05-js-api-change'); + }); + }); + + describe('lokalizacja', () => { + it('reaguje na zmianę atrybutu locale', () => { + const targetStr = new Date(NOW_TIMESTAMP + 2 * 86400000).toISOString(); + + // Wersja Polska + cy.get('wc-timer').first() + .invoke('attr', 'target', targetStr) + .invoke('attr', 'preset', 'dhms') + .invoke('attr', 'labels', 'long') + .invoke('attr', 'locale', 'pl'); + + cy.get('wc-timer').first().should('contain.text', 'dni'); + // SCREENSHOT 6: Język polski + cy.screenshot('06-locale-pl'); + + // Wersja Angielska + cy.get('wc-timer').first().invoke('attr', 'locale', 'en'); + cy.get('wc-timer').first().should('contain.text', 'days'); + // SCREENSHOT 7: Język angielski + cy.screenshot('07-locale-en'); + }); + }); + + describe('symulacja upływu czasu (ticking)', () => { + it('odlicza czas w dół co sekundę', () => { + const targetStr = new Date(NOW_TIMESTAMP + 10000).toISOString(); + + cy.get('wc-timer').first() + .invoke('attr', 'preset', 'hms') + .invoke('attr', 'labels', 'none') + .invoke('attr', 'target', targetStr); + + // Start: 10 sekund + cy.get('wc-timer').first().should('contain.text', '00:00:10'); + // SCREENSHOT 8: Start odliczania + cy.screenshot('08-tick-start'); + + // Przeskok o 5 sekund + cy.tick(5000); + cy.get('wc-timer').first().should('contain.text', '00:00:05'); + // SCREENSHOT 9: Połowa czasu + cy.screenshot('09-tick-middle'); + + // Koniec (0 sekund) + cy.tick(5000); + cy.get('wc-timer').first().should('contain.text', '00:00:00'); + // SCREENSHOT 10: Koniec odliczania + cy.screenshot('10-tick-end'); + }); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc177b2 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "wc-timer", + "version": "1.0.0", + "description": "A lightweight, dependency-free countdown timer Web Component.", + "type": "module", + "main": "src/wc-timer.js", + "exports": { + ".": "./src/wc-timer.js" + }, + "files": [ + "src" + ], + "scripts": { + "test": "echo 'Tests are running in Docker environment' && exit 0" + }, + "keywords": [ + "web-component", + "custom-elements", + "timer", + "countdown", + "date", + "time", + "zero-dependency" + ], + "author": "Dávid Ali", + "license": "MIT", + "devDependencies": { + "jest": "^30.2.0" + } +} diff --git a/public/assets/wc-timer.js b/public/assets/wc-timer.js new file mode 100644 index 0000000..98dbe02 --- /dev/null +++ b/public/assets/wc-timer.js @@ -0,0 +1,454 @@ +/** + * 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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..027873c --- /dev/null +++ b/public/index.html @@ -0,0 +1,55 @@ + + + + + wc-timer demo + + + + + + +
+

wc-timer

+ + + +

Demo & Test Environment

+
+ + + \ No newline at end of file diff --git a/src/wc-timer.js b/src/wc-timer.js new file mode 100644 index 0000000..c9a3c30 --- /dev/null +++ b/src/wc-timer.js @@ -0,0 +1,454 @@ +/** + * 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);