381 lines
11 KiB
JavaScript
381 lines
11 KiB
JavaScript
const POPULAR_NAMES = [
|
||
'New York, Vereinigte Staaten',
|
||
'London, Vereinigtes Königreich',
|
||
'Los Angeles, Vereinigte Staaten',
|
||
'Tokyo, Japan',
|
||
'Sydney, Australien',
|
||
'Singapore, Singapur',
|
||
'Dubai, Vereinigte Arabische Emirate',
|
||
'São Paulo, Brasilien',
|
||
'Berlin, Deutschland',
|
||
'Mexico City, Mexiko'
|
||
];
|
||
|
||
const timezoneByName = new Map(SORTED_TIMEZONE_DATA.map((entry) => [entry.name, entry]));
|
||
|
||
function normalizeText(value) {
|
||
return value
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '');
|
||
}
|
||
|
||
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
const localLabel = buildLocalLabel(localZone);
|
||
|
||
let selected = [
|
||
{
|
||
name: localLabel.name,
|
||
country: localLabel.country,
|
||
timeZone: localZone,
|
||
isLocal: true
|
||
}
|
||
];
|
||
|
||
const THEME_STORAGE_KEY = 'weltzeit:theme';
|
||
|
||
const localTimeEl = document.getElementById('local-time');
|
||
const localDateEl = document.getElementById('local-date');
|
||
const localZoneEl = document.getElementById('local-zone');
|
||
const searchInputEl = document.getElementById('search-input');
|
||
const searchResultsEl = document.getElementById('search-results');
|
||
const selectionListEl = document.getElementById('selection-list');
|
||
const resetButtonEl = document.getElementById('reset-button');
|
||
const themeToggleButton = document.getElementById('theme-toggle');
|
||
const themeToggleIcon = themeToggleButton?.querySelector('.theme-toggle-icon');
|
||
const themeToggleLabel = themeToggleButton?.querySelector('.theme-toggle-label');
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||
|
||
let updateTimer = null;
|
||
let hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||
|
||
function updateThemeToggleLabel(theme) {
|
||
if (!themeToggleButton || !themeToggleIcon || !themeToggleLabel) {
|
||
return;
|
||
}
|
||
if (theme === 'dark') {
|
||
themeToggleIcon.textContent = '☀︎';
|
||
themeToggleLabel.textContent = 'Heller Modus';
|
||
} else {
|
||
themeToggleIcon.textContent = '☾';
|
||
themeToggleLabel.textContent = 'Dunkler Modus';
|
||
}
|
||
}
|
||
|
||
function applyTheme(theme, { persist } = { persist: true }) {
|
||
const normalized = theme === 'dark' ? 'dark' : 'light';
|
||
document.documentElement.setAttribute('data-theme', normalized);
|
||
updateThemeToggleLabel(normalized);
|
||
if (persist) {
|
||
window.localStorage.setItem(THEME_STORAGE_KEY, normalized);
|
||
}
|
||
}
|
||
|
||
function resolveInitialTheme() {
|
||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||
if (stored === 'light' || stored === 'dark') {
|
||
return stored;
|
||
}
|
||
return prefersDark.matches ? 'dark' : 'light';
|
||
}
|
||
|
||
function buildLocalLabel(timeZone) {
|
||
const formatter = Intl.DateTimeFormat(undefined, {
|
||
timeZone,
|
||
timeZoneName: 'longGeneric'
|
||
});
|
||
|
||
const parts = formatter.formatToParts(new Date());
|
||
const zoneName = parts.find((part) => part.type === 'timeZoneName')?.value ?? timeZone;
|
||
return {
|
||
name: zoneName,
|
||
country: '',
|
||
zoneName
|
||
};
|
||
}
|
||
|
||
function formatClockValue(date, timeZone) {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
timeZone,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false
|
||
}).format(date);
|
||
}
|
||
|
||
function formatDateValue(date, timeZone) {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
timeZone,
|
||
weekday: 'long',
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
}).format(date);
|
||
}
|
||
|
||
function pad(value) {
|
||
return String(Math.abs(value)).padStart(2, '0');
|
||
}
|
||
|
||
function getOffsetMinutes(date, timeZone) {
|
||
const withParts = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone,
|
||
hour12: false,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
}).formatToParts(date);
|
||
|
||
const partValues = Object.fromEntries(
|
||
withParts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])
|
||
);
|
||
|
||
const zonedUTC = Date.UTC(
|
||
Number(partValues.year),
|
||
Number(partValues.month) - 1,
|
||
Number(partValues.day),
|
||
Number(partValues.hour),
|
||
Number(partValues.minute),
|
||
Number(partValues.second)
|
||
);
|
||
|
||
return Math.round((zonedUTC - date.getTime()) / 60000);
|
||
}
|
||
|
||
function formatOffsetLabel(offsetMinutes) {
|
||
if (offsetMinutes === 0) {
|
||
return 'Entspricht der lokalen Zeit';
|
||
}
|
||
|
||
const sign = offsetMinutes > 0 ? '+' : '-';
|
||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||
const minutes = Math.abs(offsetMinutes) % 60;
|
||
return `${sign}${pad(hours)}:${pad(minutes)}`;
|
||
}
|
||
|
||
function updateLocalClock() {
|
||
const now = new Date();
|
||
localTimeEl.textContent = formatClockValue(now, localZone);
|
||
localDateEl.textContent = formatDateValue(now, localZone);
|
||
localZoneEl.textContent = localLabel.zoneName || localZone;
|
||
}
|
||
|
||
function renderSelections() {
|
||
selectionListEl.innerHTML = '';
|
||
const now = new Date();
|
||
const baseOffset = getOffsetMinutes(now, selected[0].timeZone);
|
||
|
||
selected.forEach((entry, index) => {
|
||
const listItem = document.createElement('li');
|
||
listItem.className = 'selection-item';
|
||
if (index === 0) {
|
||
listItem.classList.add('highlight');
|
||
}
|
||
|
||
const locationContainer = document.createElement('div');
|
||
const nameEl = document.createElement('div');
|
||
nameEl.className = 'location-name';
|
||
nameEl.textContent = entry.name;
|
||
|
||
const metaEl = document.createElement('div');
|
||
metaEl.className = 'location-meta';
|
||
metaEl.textContent = [entry.country, entry.timeZone].filter(Boolean).join(' • ');
|
||
|
||
locationContainer.appendChild(nameEl);
|
||
if (metaEl.textContent) {
|
||
locationContainer.appendChild(metaEl);
|
||
}
|
||
|
||
const timeEl = document.createElement('div');
|
||
timeEl.className = 'time-value';
|
||
timeEl.textContent = formatClockValue(now, entry.timeZone);
|
||
|
||
const offsetEl = document.createElement('div');
|
||
offsetEl.className = 'offset-value';
|
||
const relativeOffset = getOffsetMinutes(now, entry.timeZone) - baseOffset;
|
||
offsetEl.textContent = formatOffsetLabel(relativeOffset);
|
||
|
||
const removeButton = document.createElement('button');
|
||
removeButton.type = 'button';
|
||
removeButton.className = 'remove-button';
|
||
removeButton.setAttribute('aria-label', `${entry.name} entfernen`);
|
||
removeButton.textContent = '×';
|
||
removeButton.addEventListener('click', () => {
|
||
removeSelection(entry.timeZone);
|
||
});
|
||
|
||
listItem.appendChild(locationContainer);
|
||
listItem.appendChild(timeEl);
|
||
listItem.appendChild(offsetEl);
|
||
if (!entry.isLocal) {
|
||
listItem.appendChild(removeButton);
|
||
} else {
|
||
const placeholder = document.createElement('div');
|
||
listItem.appendChild(placeholder);
|
||
}
|
||
|
||
selectionListEl.appendChild(listItem);
|
||
});
|
||
}
|
||
|
||
function removeSelection(timeZone) {
|
||
selected = selected.filter((entry) => !(entry.timeZone === timeZone && !entry.isLocal));
|
||
if (!selected.some((entry) => entry.isLocal)) {
|
||
selected.unshift({
|
||
name: localLabel.name,
|
||
country: localLabel.country,
|
||
timeZone: localZone,
|
||
isLocal: true
|
||
});
|
||
}
|
||
renderSelections();
|
||
}
|
||
|
||
function addSelection(entry) {
|
||
if (selected.some((item) => item.timeZone === entry.timeZone)) {
|
||
return;
|
||
}
|
||
selected.push({
|
||
name: entry.name,
|
||
country: entry.country,
|
||
timeZone: entry.timeZone
|
||
});
|
||
renderSelections();
|
||
}
|
||
|
||
function clearResults() {
|
||
searchResultsEl.innerHTML = '';
|
||
}
|
||
|
||
function renderSearchResults(query = '') {
|
||
const trimmed = query.trim().toLowerCase();
|
||
const normalizedQuery = normalizeText(trimmed);
|
||
let matches;
|
||
|
||
if (!trimmed) {
|
||
matches = POPULAR_NAMES.map((name) => timezoneByName.get(name)).filter(Boolean);
|
||
} else {
|
||
matches = SORTED_TIMEZONE_DATA.filter((entry) => {
|
||
const haystack = [
|
||
entry.name,
|
||
entry.country,
|
||
entry.timeZone,
|
||
...(entry.keywords || [])
|
||
].join(' ');
|
||
const lowerHaystack = haystack.toLowerCase();
|
||
const normalizedHaystack = normalizeText(haystack);
|
||
return lowerHaystack.includes(trimmed) || normalizedHaystack.includes(normalizedQuery);
|
||
}).slice(0, 20);
|
||
}
|
||
|
||
clearResults();
|
||
|
||
matches.forEach((entry) => {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'search-result-item';
|
||
const alreadySelected = selected.some((current) => current.timeZone === entry.timeZone);
|
||
if (alreadySelected) {
|
||
item.disabled = true;
|
||
item.classList.add('is-selected');
|
||
}
|
||
item.addEventListener('click', () => {
|
||
if (item.disabled) {
|
||
return;
|
||
}
|
||
addSelection(entry);
|
||
searchInputEl.value = '';
|
||
renderSearchResults('');
|
||
searchInputEl.focus();
|
||
});
|
||
|
||
const name = document.createElement('div');
|
||
name.className = 'result-name';
|
||
name.textContent = entry.name;
|
||
|
||
const zone = document.createElement('div');
|
||
zone.className = 'result-zone';
|
||
zone.textContent = `${entry.timeZone} • ${entry.country}`;
|
||
|
||
item.appendChild(name);
|
||
item.appendChild(zone);
|
||
searchResultsEl.appendChild(item);
|
||
});
|
||
|
||
if (!matches.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'search-result-item';
|
||
empty.textContent = 'Keine Treffer. Probiere eine andere Stadt, ein anderes Land oder eine andere Zeitzone.';
|
||
empty.style.cursor = 'default';
|
||
searchResultsEl.appendChild(empty);
|
||
}
|
||
}
|
||
|
||
function startClock() {
|
||
updateLocalClock();
|
||
renderSelections();
|
||
if (updateTimer) {
|
||
clearInterval(updateTimer);
|
||
}
|
||
updateTimer = setInterval(() => {
|
||
updateLocalClock();
|
||
renderSelections();
|
||
}, 1000);
|
||
}
|
||
|
||
searchInputEl.addEventListener('input', (event) => {
|
||
renderSearchResults(event.target.value);
|
||
});
|
||
|
||
searchInputEl.addEventListener('focus', () => {
|
||
renderSearchResults(searchInputEl.value);
|
||
});
|
||
|
||
resetButtonEl.addEventListener('click', () => {
|
||
selected = [
|
||
{
|
||
name: localLabel.name,
|
||
country: localLabel.country,
|
||
timeZone: localZone,
|
||
isLocal: true
|
||
}
|
||
];
|
||
renderSelections();
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
updateLocalClock();
|
||
renderSelections();
|
||
}
|
||
});
|
||
|
||
const initialTheme = resolveInitialTheme();
|
||
applyTheme(initialTheme, { persist: false });
|
||
hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||
|
||
window.addEventListener('load', () => {
|
||
startClock();
|
||
renderSearchResults('');
|
||
});
|
||
|
||
if (themeToggleButton) {
|
||
themeToggleButton.addEventListener('click', () => {
|
||
const current = document.documentElement.getAttribute('data-theme') || resolveInitialTheme();
|
||
const next = current === 'dark' ? 'light' : 'dark';
|
||
hasExplicitThemePreference = true;
|
||
applyTheme(next, { persist: true });
|
||
});
|
||
}
|
||
|
||
prefersDark.addEventListener('change', (event) => {
|
||
if (hasExplicitThemePreference) {
|
||
return;
|
||
}
|
||
applyTheme(event.matches ? 'dark' : 'light', { persist: false });
|
||
});
|