Files
weltze.it/app.js
2025-10-13 11:25:20 -06:00

381 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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