first commit

This commit is contained in:
David Ali
2026-01-05 18:40:48 +01:00
commit 9e03cfa3ae
12 changed files with 1295 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
e2e/cypress/screenshots/*

13
.npmignore Normal file
View File

@@ -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/

31
Dockerfile Normal file
View File

@@ -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"]

0
Jenkinsfile vendored Normal file
View File

80
README.md Normal file
View File

@@ -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
// <script type="module" src="path/to/wc-timer.js"></script>
```
2. **Use the tag in HTML**:
```html
<wc-timer target="2025-12-31T23:59:59Z"></wc-timer>
<wc-timer
target="2026-01-01T00:00:00Z"
preset="dhms"
labels="short"
compact
locale="en-US">
</wc-timer>
```
## 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

23
docker-compose.test.yml Normal file
View File

@@ -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

14
e2e/cypress.config.js Normal file
View File

@@ -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
},
});

140
e2e/cypress/wc-timer.cy.js Normal file
View File

@@ -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');
});
});
});

30
package.json Normal file
View File

@@ -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"
}
}

454
public/assets/wc-timer.js Normal file
View File

@@ -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):
* <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:
* - 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] -> <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);

55
public/index.html Normal file
View File

@@ -0,0 +1,55 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>wc-timer demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" src="/assets/wc-timer.js"></script>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f0f2f5;
}
/* Stylizacja kontenera, żeby ładnie wyglądało na screenshocie */
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-align: center;
}
h1 { margin-top: 0; color: #333; font-size: 1.5rem; }
/* Styl samego komponentu */
wc-timer {
display: block;
font-size: 2.5rem;
font-weight: bold;
color: #0066cc;
margin: 1rem 0;
font-variant-numeric: tabular-nums; /* Aby cyfry nie skakały */
}
p { color: #666; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="container">
<h1>wc-timer</h1>
<wc-timer target="2030-01-01T00:00:00Z"></wc-timer>
<p>Demo &amp; Test Environment</p>
</div>
</body>
</html>

454
src/wc-timer.js Normal file
View File

@@ -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):
* <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);