feat: localize site and expand timezone data
This commit is contained in:
380
app.js
Normal file
380
app.js
Normal file
@@ -0,0 +1,380 @@
|
||||
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 });
|
||||
});
|
Reference in New Issue
Block a user