feat: localize site and expand timezone data

This commit is contained in:
Your Name
2025-10-13 11:25:20 -06:00
commit aaa876be83
5 changed files with 1243 additions and 0 deletions

380
app.js Normal file
View 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 });
});