Compare commits

..

9 Commits

Author SHA1 Message Date
Your Name
bb337b6641 chore: add mobile web app meta tag 2025-10-13 22:59:30 -06:00
Your Name
aa76e34d69 fix: use actual timezone offsets in map tooltip 2025-10-13 22:58:45 -06:00
Your Name
2769cb8bf1 feat: add interactive map prototype 2025-10-13 22:55:07 -06:00
Your Name
749ee79619 chore: tweak footer copy 2025-10-13 22:51:58 -06:00
Your Name
b7f3837b1e fix: localize date formatting 2025-10-13 22:49:50 -06:00
Your Name
d58c0723b1 feat: add pwa support 2025-10-13 22:48:34 -06:00
Your Name
e333121999 fix: restore footer attribution 2025-10-13 11:45:47 -06:00
Your Name
80714a0b57 Merge remote-tracking branch 'origin/main' 2025-10-13 11:28:13 -06:00
Your Name
aaa876be83 feat: localize site and expand timezone data 2025-10-13 11:25:20 -06:00
12 changed files with 3521 additions and 13 deletions

29
LICENSE
View File

@@ -1,18 +1,21 @@
MIT License
Copyright (c) 2025 andreas
Copyright (c) 2025 Andreas Dueren
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

387
app.js Normal file
View File

@@ -0,0 +1,387 @@
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]));
const LOCALE = 'de-DE';
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(LOCALE, {
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(LOCALE, {
timeZone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).format(date);
}
function formatDateValue(date, timeZone) {
return new Intl.DateTimeFormat(LOCALE, {
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 ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js').catch((error) => {
console.error('Service Worker Registrierung fehlgeschlagen:', error);
});
}
});
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 });
});

1833
assets/world_time_zones.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 939 KiB

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

169
index-map.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weltze.it — Alle Zeitzonen im Blick</title>
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="styles.css">
<style>
.layout {
grid-template-rows: auto auto;
}
.map-panel {
background: var(--panel-background);
border: var(--border-thickness) solid var(--panel-border);
border-radius: 18px;
padding: clamp(1.2rem, 4vw, 1.8rem);
backdrop-filter: blur(6px);
box-shadow: var(--panel-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
}
.map-panel header {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.map-panel h3 {
font-size: 1.1rem;
font-weight: 700;
}
.map-panel p {
color: var(--text-color-alt);
font-size: 0.9rem;
line-height: 1.4;
}
.map-container {
position: relative;
width: 100%;
aspect-ratio: 12 / 5;
border: var(--border-thickness) solid var(--panel-border);
border-radius: 14px;
overflow: hidden;
background: var(--background-color-alt);
}
.map-container svg {
width: 100%;
height: 100%;
cursor: crosshair;
}
.map-overlay {
position: absolute;
inset: 0;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: rgba(255,255,255,0.85);
text-shadow: 0 3px 12px rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.2s ease;
}
.map-overlay.visible {
opacity: 1;
}
.map-tooltip {
position: absolute;
background: rgba(15, 23, 42, 0.85);
color: #f8fafc;
padding: 0.45rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
pointer-events: none;
transform: translate(-50%, -120%);
white-space: pre;
opacity: 0;
transition: opacity 0.15s ease;
}
.map-tooltip.visible {
opacity: 1;
}
@media (max-width: 960px) {
.layout {
grid-template-rows: auto;
}
.map-panel {
order: 2;
}
.comparisons {
order: 3;
}
}
</style>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512.png">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<script defer src="https://umami.due.ren/script.js" data-website-id="4692d3ad-c36a-4fb9-a7f0-182a8fe72a0b"></script>
</head>
<body>
<header class="site-header">
<div class="brand">
<h1>weltze.it</h1>
<p>Alle Zeitzonen auf den ersten Blick.</p>
</div>
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Dunklen Modus umschalten">
<span class="theme-toggle-icon" aria-hidden="true"></span>
<span class="theme-toggle-label">Dunkler Modus</span>
</button>
<div class="local-time">
<h2>Deine aktuelle Zeit</h2>
<div id="local-time" class="time-display">--:--</div>
<div id="local-date" class="date-display"></div>
<div id="local-zone" class="zone-display"></div>
</div>
</header>
<main class="layout">
<section class="search-panel">
<h3>Finde eine Stadt oder Zeitzone</h3>
<label for="search-input" class="visually-hidden">Suche Stadt, Land oder Zeitzone</label>
<input id="search-input" class="search-input" type="search" placeholder="Suche nach Städten, Ländern oder Zeitzonen…" autocomplete="off">
<div id="search-results" class="search-results"></div>
<div class="tips">
<strong>Tipp:</strong> Füge mehrere Orte hinzu, um Abweichungen direkt zu vergleichen.
</div>
</section>
<section class="map-panel">
<header>
<h3>Interaktive Zeitzonen-Karte (Prototyp)</h3>
<p>Tippe auf einen Punkt der Karte, um die nächstgelegene Zeitzone zu übernehmen. Die Karte verwendet das Wikimedia-Weltzeit-Zonen-SVG.</p>
</header>
<div id="map-container" class="map-container">
<div class="map-overlay" id="map-overlay">UTC±00:00</div>
<div class="map-tooltip" id="map-tooltip"></div>
</div>
<p class="map-disclaimer">Quelle: <a href="https://en.wikipedia.org/wiki/Time_zone">Wikipedia Time zone</a> · Lizenz: CC-BY-SA 4.0</p>
</section>
<section class="comparisons">
<header class="comparisons-header">
<h3>Ausgewählte Orte</h3>
<button id="reset-button" class="reset-button" type="button">Nur lokalen Ort behalten</button>
</header>
<div class="table-headings">
<div>Ort</div>
<div>Aktuelle Zeit</div>
<div>Abweichung zur lokalen Zeit</div>
</div>
<ul id="selection-list" class="selection-list"></ul>
</section>
</main>
<footer class="site-footer">
<p><a href="https://git.due.ren/andreas/weltze.it">Ein OpenSourceProjekt</a> von <a href="https://andreas.due.ren">Andreas Düren</a></p>
</footer>
<script src="zones.js"></script>
<script src="app.js"></script>
<script src="map-prototype.js"></script>
</body>
</html>

68
index.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weltze.it — Alle Zeitzonen im Blick</title>
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="styles.css">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512.png">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<script defer src="https://umami.due.ren/script.js" data-website-id="4692d3ad-c36a-4fb9-a7f0-182a8fe72a0b"></script>
</head>
<body>
<header class="site-header">
<div class="brand">
<h1>weltze.it</h1>
<p>Alle Zeitzonen auf den ersten Blick.</p>
</div>
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Dunklen Modus umschalten">
<span class="theme-toggle-icon" aria-hidden="true"></span>
<span class="theme-toggle-label">Dunkler Modus</span>
</button>
<div class="local-time">
<h2>Deine aktuelle Zeit</h2>
<div id="local-time" class="time-display">--:--</div>
<div id="local-date" class="date-display"></div>
<div id="local-zone" class="zone-display"></div>
</div>
</header>
<main class="layout">
<section class="search-panel">
<h3>Finde eine Stadt oder Zeitzone</h3>
<label for="search-input" class="visually-hidden">Suche Stadt, Land oder Zeitzone</label>
<input id="search-input" class="search-input" type="search" placeholder="Suche nach Städten, Ländern oder Zeitzonen…" autocomplete="off">
<div id="search-results" class="search-results"></div>
<div class="tips">
<strong>Tipp:</strong> Füge mehrere Orte hinzu, um Abweichungen direkt zu vergleichen.
</div>
</section>
<section class="comparisons">
<header class="comparisons-header">
<h3>Ausgewählte Orte</h3>
<button id="reset-button" class="reset-button" type="button">Nur lokalen Ort behalten</button>
</header>
<div class="table-headings">
<div>Ort</div>
<div>Aktuelle Zeit</div>
<div>Abweichung zur lokalen Zeit</div>
</div>
<ul id="selection-list" class="selection-list"></ul>
</section>
</main>
<footer class="site-footer">
<p><a href="https://git.due.ren/andreas/weltze.it">Ein OpenSourceProjekt</a> von <a href="https://andreas.due.ren">Andreas Düren</a></p>
</footer>
<script src="zones.js"></script>
<script src="app.js"></script>
</body>
</html>

25
manifest.webmanifest Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "Weltze.it — Alle Zeitzonen im Blick",
"short_name": "Weltze.it",
"description": "Alle Zeitzonen auf einen Blick vergleichen und Termine koordinieren.",
"lang": "de",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f172a",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

185
map-prototype.js Normal file
View File

@@ -0,0 +1,185 @@
const MAP_SVG_SOURCE = 'assets/world_time_zones.svg';
const OFFSET_PRECISION = 30; // Minuten-Schrittweite
const mapContainer = document.getElementById('map-container');
const overlayEl = document.getElementById('map-overlay');
const tooltipEl = document.getElementById('map-tooltip');
if (mapContainer) {
loadMap();
}
function loadMap() {
fetch(MAP_SVG_SOURCE)
.then((response) => response.text())
.then((svgText) => {
mapContainer.insertAdjacentHTML('afterbegin', svgText);
const svg = mapContainer.querySelector('svg');
if (!svg) {
throw new Error('SVG nicht gefunden.');
}
svg.setAttribute('viewBox', '0 0 3600 1500');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.style.width = '100%';
svg.style.height = '100%';
attachMapListeners(svg);
})
.catch((error) => {
console.error('Karte konnte nicht geladen werden:', error);
mapContainer.innerHTML = '<p>Die Zeitzonenkarte konnte nicht geladen werden.</p>';
});
}
function attachMapListeners(svg) {
const svgPoint = svg.createSVGPoint();
function getRelativePoint(event) {
svgPoint.x = event.clientX;
svgPoint.y = event.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) {
return { x: 0, y: 0 };
}
const inverse = ctm.inverse();
const transformed = svgPoint.matrixTransform(inverse);
return { x: transformed.x, y: transformed.y };
}
function handlePointerMove(event) {
const { x, y } = getRelativePoint(event);
const lon = normalizeLongitude(x, svg.viewBox.baseVal.width);
const lat = normalizeLatitude(y, svg.viewBox.baseVal.height);
const offsetMinutes = approximateOffsetMinutes(lon);
const candidate = findTimezoneByOffset(offsetMinutes);
if (candidate) {
const suggestion = buildSuggestion(candidate);
positionTooltip(event.clientX, event.clientY, `${suggestion.offsetLabel}\n${candidate.name}`);
} else {
const { offsetLabel } = describeOffset(offsetMinutes);
positionTooltip(event.clientX, event.clientY, `${offsetLabel}\n${formatCoordinate(lat, lon)}`);
}
}
function handlePointerLeave() {
tooltipEl?.classList.remove('visible');
}
function handleClick(event) {
const { x } = getRelativePoint(event);
const lon = normalizeLongitude(x, svg.viewBox.baseVal.width);
const offsetMinutes = approximateOffsetMinutes(lon);
const candidate = findTimezoneByOffset(offsetMinutes);
let labelToShow;
if (candidate) {
const suggestion = buildSuggestion(candidate);
labelToShow = `${suggestion.offsetLabel}`;
if (typeof addSelection === 'function') {
addSelection(candidate);
renderSelections();
}
} else {
const { offsetLabel } = describeOffset(offsetMinutes);
labelToShow = offsetLabel;
}
showOverlay(labelToShow);
}
svg.addEventListener('pointermove', handlePointerMove);
svg.addEventListener('pointerleave', handlePointerLeave);
svg.addEventListener('click', handleClick);
}
function normalizeLongitude(x, width) {
const clampedWidth = width || 3600;
return (x / clampedWidth) * 360 - 180;
}
function normalizeLatitude(y, height) {
const clampedHeight = height || 1500;
return 90 - (y / clampedHeight) * 180;
}
function approximateOffsetMinutes(longitude) {
const offsetHours = Math.round((longitude / 15) * (60 / OFFSET_PRECISION)) * (OFFSET_PRECISION / 60);
const clampedHours = Math.min(14, Math.max(-12, offsetHours));
return Math.round(clampedHours * 60);
}
function describeOffset(offsetMinutes) {
const clamped = Math.round(offsetMinutes / OFFSET_PRECISION) * OFFSET_PRECISION;
const hours = Math.trunc(clamped / 60);
const minutes = Math.abs(clamped % 60);
const sign = clamped >= 0 ? '+' : '-';
return {
offsetMinutesRounded: clamped,
offsetLabel: `UTC${sign}${String(Math.abs(hours)).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
};
}
function positionTooltip(clientX, clientY, text) {
if (!tooltipEl) {
return;
}
const containerRect = mapContainer.getBoundingClientRect();
tooltipEl.textContent = text;
tooltipEl.style.left = `${clientX - containerRect.left}px`;
tooltipEl.style.top = `${clientY - containerRect.top}px`;
tooltipEl.classList.add('visible');
}
let overlayTimeoutId = null;
function showOverlay(text) {
if (!overlayEl) {
return;
}
overlayEl.textContent = text;
overlayEl.classList.add('visible');
if (overlayTimeoutId) {
clearTimeout(overlayTimeoutId);
}
overlayTimeoutId = window.setTimeout(() => {
overlayEl?.classList.remove('visible');
}, 1500);
}
function formatCoordinate(lat, lon) {
const formatValue = (value, positiveSuffix, negativeSuffix) => {
const suffix = value >= 0 ? positiveSuffix : negativeSuffix;
return `${Math.abs(value).toFixed(1)}°${suffix}`;
};
return `${formatValue(lat, 'N', 'S')} · ${formatValue(lon, 'E', 'W')}`;
}
function findTimezoneByOffset(targetOffsetMinutes) {
const now = new Date();
let bestCandidate = null;
let bestDelta = Infinity;
SORTED_TIMEZONE_DATA.forEach((entry) => {
const offset = getOffsetMinutes(now, entry.timeZone);
const delta = Math.abs(offset - targetOffsetMinutes);
if (delta < bestDelta) {
bestDelta = delta;
bestCandidate = entry;
}
});
return bestCandidate;
}
function buildSuggestion(entry) {
const now = new Date();
const offsetMinutes = getOffsetMinutes(now, entry.timeZone);
const hours = Math.trunc(offsetMinutes / 60);
const minutes = Math.abs(offsetMinutes % 60);
const sign = offsetMinutes >= 0 ? '+' : '-';
return {
offsetMinutes,
offsetLabel: `UTC${sign}${String(Math.abs(hours)).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
};
}

56
service-worker.js Normal file
View File

@@ -0,0 +1,56 @@
const CACHE_NAME = 'weltzeit-cache-v1';
const PRECACHE_RESOURCES = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./zones.js',
'./manifest.webmanifest',
'./icons/icon-192.png',
'./icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_RESOURCES))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
return undefined;
})
)
)
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match('./index.html'));
})
);
});

470
styles.css Normal file
View File

@@ -0,0 +1,470 @@
@font-face {
font-family: 'Berkeley Mono';
src: url('https://static.due.ren/site/font/BerkeleyMono-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Berkeley Mono';
src: url('https://static.due.ren/site/font/BerkeleyMono-Oblique.otf') format('opentype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Berkeley Mono';
src: url('https://static.due.ren/site/font/BerkeleyMono-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Berkeley Mono';
src: url('https://static.due.ren/site/font/BerkeleyMono-Bold-Oblique.otf') format('opentype');
font-weight: 700;
font-style: italic;
font-display: swap;
}
:root {
--font-family: 'Berkeley Mono', monospace;
--line-height: 1.20rem;
--border-thickness: 2px;
--text-color: #000;
--text-color-alt: #666;
--background-color: #fff;
--background-color-alt: #eee;
--panel-background: #f7f7f9;
--panel-border: rgba(0, 0, 0, 0.12);
--panel-border-strong: rgba(0, 0, 0, 0.2);
--panel-shadow: 0 30px 60px -40px rgba(15, 23, 42, 0.25);
--accent-color: #0f172a;
--accent-color-soft: rgba(15, 23, 42, 0.08);
--accent-color-strong: rgba(15, 23, 42, 0.16);
--highlight-border: rgba(15, 23, 42, 0.35);
--highlight-background: rgba(15, 23, 42, 0.08);
--interactive-fill: #0f172a;
--interactive-fill-muted: rgba(15, 23, 42, 0.08);
--interactive-contrast: #fff;
--muted-border: rgba(0, 0, 0, 0.08);
--focus-ring: rgba(15, 23, 42, 0.3);
font-family: var(--font-family);
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variant-numeric: tabular-nums lining-nums;
font-size: 16px;
}
:root[data-theme='dark'] {
--text-color: #f8fafc;
--text-color-alt: #cbd5f5;
--background-color: #060814;
--background-color-alt: #091025;
--panel-background: rgba(15, 23, 42, 0.65);
--panel-border: rgba(148, 163, 184, 0.28);
--panel-border-strong: rgba(148, 163, 184, 0.45);
--panel-shadow: 0 30px 80px -40px rgba(2, 6, 23, 0.9);
--accent-color: #67e8f9;
--accent-color-soft: rgba(94, 234, 212, 0.18);
--accent-color-strong: rgba(56, 189, 248, 0.35);
--highlight-border: rgba(94, 234, 212, 0.45);
--highlight-background: rgba(14, 165, 233, 0.22);
--interactive-fill: rgba(94, 234, 212, 0.24);
--interactive-fill-muted: rgba(148, 163, 184, 0.12);
--interactive-contrast: #e0f2fe;
--muted-border: rgba(148, 163, 184, 0.22);
--focus-ring: rgba(94, 234, 212, 0.45);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
min-height: 100%;
background: var(--background-color);
color: var(--text-color);
}
body {
display: flex;
flex-direction: column;
line-height: 1.6;
transition: background 0.4s ease, color 0.4s ease;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover,
a:focus-visible {
text-decoration: underline;
}
.site-header {
padding: clamp(1.8rem, 6vw, 3rem) clamp(1.2rem, 4vw, 3.4rem);
display: grid;
grid-template-columns: minmax(200px, 2fr) auto minmax(200px, 2fr);
align-items: end;
gap: clamp(1rem, 4vw, 3rem);
background: linear-gradient(120deg, var(--background-color-alt), var(--background-color));
border-bottom: var(--border-thickness) solid var(--muted-border);
}
.brand h1 {
font-size: clamp(2.1rem, 6vw, 3.2rem);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand p {
margin-top: 0.5rem;
max-width: 28rem;
color: var(--text-color-alt);
}
.theme-toggle {
justify-self: center;
align-self: start;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 1.2rem;
border-radius: 999px;
border: var(--border-thickness) solid var(--panel-border);
background: var(--panel-background);
color: var(--text-color);
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.4s ease, color 0.4s ease;
box-shadow: var(--panel-shadow);
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
transform: translateY(-1px);
border-color: var(--accent-color-strong);
}
.theme-toggle:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.theme-toggle-icon {
font-size: 1.25rem;
}
.theme-toggle-label {
font-size: 0.95rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.local-time {
justify-self: end;
text-align: right;
}
.local-time h2 {
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-color-alt);
}
.time-display {
font-size: clamp(4.2rem, 12vw, 7.8rem);
font-weight: 700;
letter-spacing: 0.05em;
margin-top: 0.3rem;
}
.date-display,
.zone-display {
margin-top: 0.3rem;
font-weight: 500;
color: var(--text-color-alt);
}
.layout {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(300px, 1fr);
gap: clamp(1.8rem, 5vw, 3rem);
padding: clamp(2rem, 5vw, 3.2rem) clamp(1.2rem, 4vw, 3.4rem);
flex: 1;
}
.search-panel,
.comparisons {
background: var(--panel-background);
border: var(--border-thickness) solid var(--panel-border);
border-radius: 18px;
padding: clamp(1.2rem, 4vw, 1.8rem);
backdrop-filter: blur(6px);
box-shadow: var(--panel-shadow);
transition: background 0.4s ease, border-color 0.4s ease, color 0.4s ease, box-shadow 0.4s ease;
}
.search-panel h3,
.comparisons h3 {
font-size: 1.1rem;
font-weight: 700;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.search-panel h3 {
margin-bottom: 0.8rem;
}
.search-input {
width: 100%;
padding: 0.95rem 1rem;
border-radius: 12px;
border: var(--border-thickness) solid var(--muted-border);
background: var(--background-color);
color: inherit;
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.4s ease, color 0.4s ease;
}
.search-input:focus {
border-color: var(--accent-color);
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.search-results {
margin-top: 0.9rem;
display: grid;
gap: 0.6rem;
max-height: 320px;
overflow-y: auto;
padding-right: 0.3rem;
}
.search-result-item {
padding: 0.8rem 0.95rem;
border-radius: 12px;
border: var(--border-thickness) solid transparent;
background: var(--accent-color-soft);
color: var(--text-color);
text-align: left;
cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.4s ease, color 0.4s ease;
}
.search-result-item:hover,
.search-result-item:focus-visible {
transform: translateY(-1px);
border-color: var(--accent-color-strong);
}
.search-result-item:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.search-result-item .result-name {
font-weight: 600;
}
.search-result-item .result-zone {
font-size: 0.85rem;
color: var(--text-color-alt);
margin-top: 0.25rem;
}
.search-result-item.is-selected {
cursor: not-allowed;
opacity: 0.45;
border-color: var(--muted-border);
background: var(--interactive-fill-muted);
}
.tips {
margin-top: 1.3rem;
font-size: 0.9rem;
color: var(--text-color-alt);
line-height: 1.4;
}
.comparisons {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.comparisons-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.reset-button {
border: var(--border-thickness) solid transparent;
border-radius: 999px;
padding: 0.45rem 1rem;
background: var(--interactive-fill-muted);
color: var(--text-color);
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease, background 0.4s ease, color 0.4s ease;
}
.reset-button:hover,
.reset-button:focus-visible {
border-color: var(--accent-color);
transform: translateY(-1px);
}
.reset-button:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.table-headings {
display: grid;
grid-template-columns: 1.3fr 1fr 0.9fr;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color-alt);
}
.selection-list {
display: grid;
gap: 0.9rem;
list-style: none;
}
.selection-item {
display: grid;
grid-template-columns: 1.3fr 1fr 0.9fr auto;
align-items: center;
gap: 0.9rem;
padding: 1rem 1.1rem;
border-radius: 14px;
border: var(--border-thickness) solid transparent;
background: var(--panel-background);
box-shadow: var(--panel-shadow);
transition: background 0.4s ease, border-color 0.4s ease, color 0.4s ease, box-shadow 0.4s ease;
}
.selection-item.highlight {
border-color: var(--highlight-border);
background: var(--highlight-background);
}
.selection-item .location-name {
font-weight: 700;
}
.selection-item .location-meta {
font-size: 0.85rem;
color: var(--text-color-alt);
margin-top: 0.2rem;
}
.selection-item .time-value {
font-variant-numeric: tabular-nums;
font-size: 1.25rem;
font-weight: 700;
}
.selection-item .offset-value {
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
color: var(--text-color-alt);
}
.remove-button {
border: none;
background: transparent;
color: var(--text-color-alt);
font-size: 1.1rem;
cursor: pointer;
transition: color 0.15s ease, transform 0.15s ease;
}
.remove-button:hover,
.remove-button:focus-visible {
color: var(--accent-color);
transform: translateY(-1px);
}
.remove-button:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.site-footer {
padding: 1.4rem clamp(1.2rem, 4vw, 3rem) 2.2rem;
text-align: center;
font-size: 0.85rem;
color: var(--text-color-alt);
}
@media (max-width: 1080px) {
.site-header {
grid-template-columns: 1fr;
justify-items: flex-start;
text-align: left;
}
.theme-toggle {
justify-self: flex-start;
}
.local-time {
justify-self: flex-start;
text-align: left;
}
.layout {
grid-template-columns: 1fr;
}
.selection-item {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.selection-item .time-value {
font-size: 1.5rem;
}
.selection-item .offset-value {
font-size: 1.05rem;
}
.table-headings {
display: none;
}
}

312
zones.js Normal file
View File

@@ -0,0 +1,312 @@
// Principal cities sourced from https://en.wikipedia.org/wiki/List_of_UTC_offsets
const PRINCIPAL_CITY_DATA = [
{ name: 'Honolulu, United States', country: 'United States', timeZone: 'Pacific/Honolulu' },
{ name: 'Anchorage, United States', country: 'United States', timeZone: 'America/Anchorage' },
{ name: 'Los Angeles, United States', country: 'United States', timeZone: 'America/Los_Angeles' },
{ name: 'Vancouver, Canada', country: 'Canada', timeZone: 'America/Vancouver' },
{ name: 'Tijuana, Mexico', country: 'Mexico', timeZone: 'America/Tijuana' },
{ name: 'Denver, United States', country: 'United States', timeZone: 'America/Denver' },
{ name: 'Calgary, Canada', country: 'Canada', timeZone: 'America/Edmonton' },
{ name: 'Ciudad Juárez, Mexico', country: 'Mexico', timeZone: 'America/Ciudad_Juarez' },
{ name: 'Mexico City, Mexico', country: 'Mexico', timeZone: 'America/Mexico_City' },
{ name: 'Chicago, United States', country: 'United States', timeZone: 'America/Chicago' },
{ name: 'Guatemala City, Guatemala', country: 'Guatemala', timeZone: 'America/Guatemala' },
{ name: 'Tegucigalpa, Honduras', country: 'Honduras', timeZone: 'America/Tegucigalpa' },
{ name: 'Winnipeg, Canada', country: 'Canada', timeZone: 'America/Winnipeg' },
{ name: 'San José, Costa Rica', country: 'Costa Rica', timeZone: 'America/Costa_Rica' },
{ name: 'San Salvador, El Salvador', country: 'El Salvador', timeZone: 'America/El_Salvador' },
{ name: 'New York, United States', country: 'United States', timeZone: 'America/New_York' },
{ name: 'Toronto, Canada', country: 'Canada', timeZone: 'America/Toronto' },
{ name: 'Havana, Cuba', country: 'Cuba', timeZone: 'America/Havana' },
{ name: 'Lima, Peru', country: 'Peru', timeZone: 'America/Lima' },
{ name: 'Bogotá, Colombia', country: 'Colombia', timeZone: 'America/Bogota' },
{ name: 'Kingston, Jamaica', country: 'Jamaica', timeZone: 'America/Jamaica' },
{ name: 'Quito, Ecuador', country: 'Ecuador', timeZone: 'America/Guayaquil' },
{ name: 'Santiago, Chile', country: 'Chile', timeZone: 'America/Santiago' },
{ name: 'Santo Domingo, Dominican Republic', country: 'Dominican Republic', timeZone: 'America/Santo_Domingo' },
{ name: 'Manaus, Brazil', country: 'Brazil', timeZone: 'America/Manaus' },
{ name: 'Caracas, Venezuela', country: 'Venezuela', timeZone: 'America/Caracas' },
{ name: 'La Paz, Bolivia', country: 'Bolivia', timeZone: 'America/La_Paz' },
{ name: 'Halifax, Canada', country: 'Canada', timeZone: 'America/Halifax' },
{ name: 'St. John\'s, Canada', country: 'Canada', timeZone: 'America/St_Johns' },
{ name: 'São Paulo, Brazil', country: 'Brazil', timeZone: 'America/Sao_Paulo' },
{ name: 'Buenos Aires, Argentina', country: 'Argentina', timeZone: 'America/Argentina/Buenos_Aires' },
{ name: 'Montevideo, Uruguay', country: 'Uruguay', timeZone: 'America/Montevideo' },
{ name: 'London, United Kingdom', country: 'United Kingdom', timeZone: 'Europe/London' },
{ name: 'Dublin, Ireland', country: 'Ireland', timeZone: 'Europe/Dublin' },
{ name: 'Lisbon, Portugal', country: 'Portugal', timeZone: 'Europe/Lisbon' },
{ name: 'Abidjan, Côte d\'Ivoire', country: 'Côte d\'Ivoire', timeZone: 'Africa/Abidjan' },
{ name: 'Accra, Ghana', country: 'Ghana', timeZone: 'Africa/Accra' },
{ name: 'Dakar, Senegal', country: 'Senegal', timeZone: 'Africa/Dakar' },
{ name: 'Berlin, Germany', country: 'Germany', timeZone: 'Europe/Berlin' },
{ name: 'Frankfurt, Germany', country: 'Germany', timeZone: 'Europe/Berlin' },
{ name: 'Rome, Italy', country: 'Italy', timeZone: 'Europe/Rome' },
{ name: 'Stockholm, Sweden', country: 'Sweden', timeZone: 'Europe/Stockholm' },
{ name: 'Paris, France', country: 'France', timeZone: 'Europe/Paris' },
{ name: 'Madrid, Spain', country: 'Spain', timeZone: 'Europe/Madrid' },
{ name: 'Warsaw, Poland', country: 'Poland', timeZone: 'Europe/Warsaw' },
{ name: 'Lagos, Nigeria', country: 'Nigeria', timeZone: 'Africa/Lagos' },
{ name: 'Kinshasa, DR Congo', country: 'Democratic Republic of the Congo', timeZone: 'Africa/Kinshasa' },
{ name: 'Algiers, Algeria', country: 'Algeria', timeZone: 'Africa/Algiers' },
{ name: 'Casablanca, Morocco', country: 'Morocco', timeZone: 'Africa/Casablanca' },
{ name: 'Athens, Greece', country: 'Greece', timeZone: 'Europe/Athens' },
{ name: 'Bucharest, Romania', country: 'Romania', timeZone: 'Europe/Bucharest' },
{ name: 'Cairo, Egypt', country: 'Egypt', timeZone: 'Africa/Cairo' },
{ name: 'Helsinki, Finland', country: 'Finland', timeZone: 'Europe/Helsinki' },
{ name: 'Jerusalem, Israel', country: 'Israel', timeZone: 'Asia/Jerusalem' },
{ name: 'Johannesburg, South Africa', country: 'South Africa', timeZone: 'Africa/Johannesburg' },
{ name: 'Khartoum, Sudan', country: 'Sudan', timeZone: 'Africa/Khartoum' },
{ name: 'Kyiv, Ukraine', country: 'Ukraine', timeZone: 'Europe/Kyiv' },
{ name: 'Riga, Latvia', country: 'Latvia', timeZone: 'Europe/Riga' },
{ name: 'Sofia, Bulgaria', country: 'Bulgaria', timeZone: 'Europe/Sofia' },
{ name: 'Moscow, Russia', country: 'Russia', timeZone: 'Europe/Moscow' },
{ name: 'Istanbul, Türkiye', country: 'Türkiye', timeZone: 'Europe/Istanbul' },
{ name: 'Riyadh, Saudi Arabia', country: 'Saudi Arabia', timeZone: 'Asia/Riyadh' },
{ name: 'Baghdad, Iraq', country: 'Iraq', timeZone: 'Asia/Baghdad' },
{ name: 'Addis Ababa, Ethiopia', country: 'Ethiopia', timeZone: 'Africa/Addis_Ababa' },
{ name: 'Doha, Qatar', country: 'Qatar', timeZone: 'Asia/Qatar' },
{ name: 'Nairobi, Kenya', country: 'Kenya', timeZone: 'Africa/Nairobi' },
{ name: 'Kuwait City, Kuwait', country: 'Kuwait', timeZone: 'Asia/Kuwait' },
{ name: 'Tehran, Iran', country: 'Iran', timeZone: 'Asia/Tehran' },
{ name: 'Dubai, United Arab Emirates', country: 'United Arab Emirates', timeZone: 'Asia/Dubai' },
{ name: 'Baku, Azerbaijan', country: 'Azerbaijan', timeZone: 'Asia/Baku' },
{ name: 'Tbilisi, Georgia', country: 'Georgia', timeZone: 'Asia/Tbilisi' },
{ name: 'Yerevan, Armenia', country: 'Armenia', timeZone: 'Asia/Yerevan' },
{ name: 'Samara, Russia', country: 'Russia', timeZone: 'Europe/Samara' },
{ name: 'Kabul, Afghanistan', country: 'Afghanistan', timeZone: 'Asia/Kabul' },
{ name: 'Karachi, Pakistan', country: 'Pakistan', timeZone: 'Asia/Karachi' },
{ name: 'Astana, Kazakhstan', country: 'Kazakhstan', timeZone: 'Asia/Almaty' },
{ name: 'Tashkent, Uzbekistan', country: 'Uzbekistan', timeZone: 'Asia/Tashkent' },
{ name: 'Yekaterinburg, Russia', country: 'Russia', timeZone: 'Asia/Yekaterinburg' },
{ name: 'Delhi, India', country: 'India', timeZone: 'Asia/Kolkata' },
{ name: 'Mumbai, India', country: 'India', timeZone: 'Asia/Kolkata' },
{ name: 'Colombo, Sri Lanka', country: 'Sri Lanka', timeZone: 'Asia/Colombo' },
{ name: 'Kathmandu, Nepal', country: 'Nepal', timeZone: 'Asia/Kathmandu' },
{ name: 'Dhaka, Bangladesh', country: 'Bangladesh', timeZone: 'Asia/Dhaka' },
{ name: 'Omsk, Russia', country: 'Russia', timeZone: 'Asia/Omsk' },
{ name: 'Bishkek, Kyrgyzstan', country: 'Kyrgyzstan', timeZone: 'Asia/Bishkek' },
{ name: 'Yangon, Myanmar', country: 'Myanmar', timeZone: 'Asia/Yangon' },
{ name: 'Jakarta, Indonesia', country: 'Indonesia', timeZone: 'Asia/Jakarta' },
{ name: 'Ho Chi Minh City, Vietnam', country: 'Vietnam', timeZone: 'Asia/Ho_Chi_Minh' },
{ name: 'Bangkok, Thailand', country: 'Thailand', timeZone: 'Asia/Bangkok' },
{ name: 'Krasnoyarsk, Russia', country: 'Russia', timeZone: 'Asia/Krasnoyarsk' },
{ name: 'Shanghai, China', country: 'China', timeZone: 'Asia/Shanghai' },
{ name: 'Taipei, Taiwan', country: 'Taiwan', timeZone: 'Asia/Taipei' },
{ name: 'Hong Kong, China', country: 'China', timeZone: 'Asia/Hong_Kong' },
{ name: 'Kuala Lumpur, Malaysia', country: 'Malaysia', timeZone: 'Asia/Kuala_Lumpur' },
{ name: 'Singapore, Singapore', country: 'Singapore', timeZone: 'Asia/Singapore' },
{ name: 'Perth, Australia', country: 'Australia', timeZone: 'Australia/Perth' },
{ name: 'Manila, Philippines', country: 'Philippines', timeZone: 'Asia/Manila' },
{ name: 'Makassar, Indonesia', country: 'Indonesia', timeZone: 'Asia/Makassar' },
{ name: 'Irkutsk, Russia', country: 'Russia', timeZone: 'Asia/Irkutsk' },
{ name: 'Tokyo, Japan', country: 'Japan', timeZone: 'Asia/Tokyo' },
{ name: 'Seoul, South Korea', country: 'South Korea', timeZone: 'Asia/Seoul' },
{ name: 'Pyongyang, North Korea', country: 'North Korea', timeZone: 'Asia/Pyongyang' },
{ name: 'Jayapura, Indonesia', country: 'Indonesia', timeZone: 'Asia/Jayapura' },
{ name: 'Chita, Russia', country: 'Russia', timeZone: 'Asia/Chita' },
{ name: 'Adelaide, Australia', country: 'Australia', timeZone: 'Australia/Adelaide' },
{ name: 'Sydney, Australia', country: 'Australia', timeZone: 'Australia/Sydney' },
{ name: 'Melbourne, Australia', country: 'Australia', timeZone: 'Australia/Melbourne' },
{ name: 'Brisbane, Australia', country: 'Australia', timeZone: 'Australia/Brisbane' },
{ name: 'Port Moresby, Papua New Guinea', country: 'Papua New Guinea', timeZone: 'Pacific/Port_Moresby' },
{ name: 'Vladivostok, Russia', country: 'Russia', timeZone: 'Asia/Vladivostok' },
{ name: 'Nouméa, New Caledonia', country: 'New Caledonia', timeZone: 'Pacific/Noumea' },
{ name: 'Auckland, New Zealand', country: 'New Zealand', timeZone: 'Pacific/Auckland' },
{ name: 'Suva, Fiji', country: 'Fiji', timeZone: 'Pacific/Fiji' },
{ name: 'Petropavlovsk-Kamchatsky, Russia', country: 'Russia', timeZone: 'Asia/Kamchatka' }
];
// Additional curated list to provide broader coverage beyond the principal cities list.
const ADDITIONAL_CITY_DATA = [
{ name: 'Amsterdam, Netherlands', country: 'Netherlands', timeZone: 'Europe/Amsterdam', keywords: ['ams', 'holland', 'nl'] },
{ name: 'Auckland, New Zealand', country: 'New Zealand', timeZone: 'Pacific/Auckland' },
{ name: 'Bangkok, Thailand', country: 'Thailand', timeZone: 'Asia/Bangkok', keywords: ['thai'] },
{ name: 'Barcelona, Spain', country: 'Spain', timeZone: 'Europe/Madrid', keywords: ['catalonia', 'esp'] },
{ name: 'Beijing, China', country: 'China', timeZone: 'Asia/Shanghai', keywords: ['cn'] },
{ name: 'Berlin, Germany', country: 'Germany', timeZone: 'Europe/Berlin', keywords: ['de'] },
{ name: 'Bogotá, Colombia', country: 'Colombia', timeZone: 'America/Bogota', keywords: ['co'] },
{ name: 'Boston, United States', country: 'United States', timeZone: 'America/New_York', keywords: ['est'] },
{ name: 'Cape Town, South Africa', country: 'South Africa', timeZone: 'Africa/Johannesburg', keywords: ['cpt'] },
{ name: 'Copenhagen, Denmark', country: 'Denmark', timeZone: 'Europe/Copenhagen', keywords: ['dk'] },
{ name: 'Dubai, United Arab Emirates', country: 'United Arab Emirates', timeZone: 'Asia/Dubai', keywords: ['uae'] },
{ name: 'Dublin, Ireland', country: 'Ireland', timeZone: 'Europe/Dublin', keywords: ['ie'] },
{ name: 'Frankfurt, Germany', country: 'Germany', timeZone: 'Europe/Berlin', keywords: ['fra'] },
{ name: 'Hong Kong, China', country: 'China', timeZone: 'Asia/Hong_Kong', keywords: ['hk'] },
{ name: 'Istanbul, Türkiye', country: 'Türkiye', timeZone: 'Europe/Istanbul', keywords: ['istanbul'] },
{ name: 'Jakarta, Indonesia', country: 'Indonesia', timeZone: 'Asia/Jakarta', keywords: ['id'] },
{ name: 'Johannesburg, South Africa', country: 'South Africa', timeZone: 'Africa/Johannesburg', keywords: ['za'] },
{ name: 'Kuala Lumpur, Malaysia', country: 'Malaysia', timeZone: 'Asia/Kuala_Lumpur', keywords: ['my'] },
{ name: 'Lisbon, Portugal', country: 'Portugal', timeZone: 'Europe/Lisbon', keywords: ['pt'] },
{ name: 'London, United Kingdom', country: 'United Kingdom', timeZone: 'Europe/London', keywords: ['uk', 'gb', 'gmt'] },
{ name: 'Los Angeles, United States', country: 'United States', timeZone: 'America/Los_Angeles', keywords: ['la', 'pst'] },
{ name: 'Mexico City, Mexico', country: 'Mexico', timeZone: 'America/Mexico_City', keywords: ['mx', 'cdmx'] },
{ name: 'Miami, United States', country: 'United States', timeZone: 'America/New_York', keywords: ['florida'] },
{ name: 'Mumbai, India', country: 'India', timeZone: 'Asia/Kolkata', keywords: ['in', 'bombay'] },
{ name: 'New York, United States', country: 'United States', timeZone: 'America/New_York', keywords: ['nyc'] },
{ name: 'Oslo, Norway', country: 'Norway', timeZone: 'Europe/Oslo', keywords: ['no'] },
{ name: 'Paris, France', country: 'France', timeZone: 'Europe/Paris', keywords: ['fr'] },
{ name: 'Reykjavík, Iceland', country: 'Iceland', timeZone: 'Atlantic/Reykjavik', keywords: ['iceland'] },
{ name: 'San Francisco, United States', country: 'United States', timeZone: 'America/Los_Angeles', keywords: ['sf', 'bay'] },
{ name: 'São Paulo, Brazil', country: 'Brazil', timeZone: 'America/Sao_Paulo', keywords: ['br'] },
{ name: 'Singapore, Singapore', country: 'Singapore', timeZone: 'Asia/Singapore', keywords: ['sg'] },
{ name: 'Stockholm, Sweden', country: 'Sweden', timeZone: 'Europe/Stockholm', keywords: ['se'] },
{ name: 'Sydney, Australia', country: 'Australia', timeZone: 'Australia/Sydney', keywords: ['au'] },
{ name: 'Tokyo, Japan', country: 'Japan', timeZone: 'Asia/Tokyo', keywords: ['jp'] },
{ name: 'Toronto, Canada', country: 'Canada', timeZone: 'America/Toronto', keywords: ['ca'] },
{ name: 'Vienna, Austria', country: 'Austria', timeZone: 'Europe/Vienna', keywords: ['at'] },
{ name: 'Warsaw, Poland', country: 'Poland', timeZone: 'Europe/Warsaw', keywords: ['pl'] },
{ name: 'Zurich, Switzerland', country: 'Switzerland', timeZone: 'Europe/Zurich', keywords: ['ch'] }
];
function deduplicateByKey(list) {
const map = new Map();
list.forEach((entry) => {
const key = `${entry.name.toLowerCase()}|${entry.timeZone}`;
const keywords = Array.isArray(entry.keywords) ? entry.keywords : [];
if (!map.has(key)) {
map.set(key, {
...entry,
keywords
});
} else {
const existing = map.get(key);
const mergedKeywords = new Set([...(existing.keywords || []), ...keywords]);
existing.keywords = Array.from(mergedKeywords);
if (!existing.country && entry.country) {
existing.country = entry.country;
}
}
});
return Array.from(map.values());
}
const RAW_TIMEZONE_DATA = deduplicateByKey([...PRINCIPAL_CITY_DATA, ...ADDITIONAL_CITY_DATA]);
const COUNTRY_TRANSLATIONS = {
Afghanistan: 'Afghanistan',
Algeria: 'Algerien',
Argentina: 'Argentinien',
Armenia: 'Armenien',
Australia: 'Australien',
Austria: 'Österreich',
Azerbaijan: 'Aserbaidschan',
Bangladesh: 'Bangladesch',
Bolivia: 'Bolivien',
Brazil: 'Brasilien',
Bulgaria: 'Bulgarien',
Canada: 'Kanada',
Chile: 'Chile',
China: 'China',
Colombia: 'Kolumbien',
'Costa Rica': 'Costa Rica',
Cuba: 'Kuba',
"Côte d'Ivoire": 'Elfenbeinküste',
'Democratic Republic of the Congo': 'Demokratische Republik Kongo',
Denmark: 'Dänemark',
'Dominican Republic': 'Dominikanische Republik',
Ecuador: 'Ecuador',
Egypt: 'Ägypten',
'El Salvador': 'El Salvador',
Ethiopia: 'Äthiopien',
Fiji: 'Fidschi',
Finland: 'Finnland',
France: 'Frankreich',
Georgia: 'Georgien',
Germany: 'Deutschland',
Ghana: 'Ghana',
Greece: 'Griechenland',
Guatemala: 'Guatemala',
Honduras: 'Honduras',
Iceland: 'Island',
India: 'Indien',
Indonesia: 'Indonesien',
Iran: 'Iran',
Iraq: 'Irak',
Ireland: 'Irland',
Israel: 'Israel',
Italy: 'Italien',
Jamaica: 'Jamaika',
Japan: 'Japan',
Kazakhstan: 'Kasachstan',
Kenya: 'Kenia',
Kuwait: 'Kuwait',
Kyrgyzstan: 'Kirgisistan',
Latvia: 'Lettland',
Malaysia: 'Malaysia',
Mexico: 'Mexiko',
Morocco: 'Marokko',
Myanmar: 'Myanmar',
Nepal: 'Nepal',
Netherlands: 'Niederlande',
'New Caledonia': 'Neukaledonien',
'New Zealand': 'Neuseeland',
Nigeria: 'Nigeria',
'North Korea': 'Nordkorea',
Norway: 'Norwegen',
Pakistan: 'Pakistan',
'Papua New Guinea': 'Papua-Neuguinea',
Peru: 'Peru',
Philippines: 'Philippinen',
Poland: 'Polen',
Portugal: 'Portugal',
Qatar: 'Katar',
Romania: 'Rumänien',
Russia: 'Russland',
'Saudi Arabia': 'Saudi-Arabien',
Senegal: 'Senegal',
Singapore: 'Singapur',
'South Africa': 'Südafrika',
'South Korea': 'Südkorea',
Spain: 'Spanien',
'Sri Lanka': 'Sri Lanka',
Sudan: 'Sudan',
Sweden: 'Schweden',
Switzerland: 'Schweiz',
Taiwan: 'Taiwan',
Thailand: 'Thailand',
Türkiye: 'Türkei',
Ukraine: 'Ukraine',
'United Arab Emirates': 'Vereinigte Arabische Emirate',
'United Kingdom': 'Vereinigtes Königreich',
'United States': 'Vereinigte Staaten',
Uruguay: 'Uruguay',
Uzbekistan: 'Usbekistan',
Venezuela: 'Venezuela',
Vietnam: 'Vietnam'
};
function translateCountry(country) {
return COUNTRY_TRANSLATIONS[country] || country;
}
function addGermanKeywordVariants(keywords, germanValue) {
const variants = new Set(keywords);
const lower = germanValue.toLowerCase();
variants.add(lower);
variants.add(
lower
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
);
return Array.from(variants);
}
const TIMEZONE_DATA = RAW_TIMEZONE_DATA.map((entry) => {
const translatedCountry = translateCountry(entry.country);
const city = entry.name.split(',')[0].trim();
const baseKeywords = Array.isArray(entry.keywords) ? entry.keywords : [];
const keywordsWithCountries = addGermanKeywordVariants(
addGermanKeywordVariants(baseKeywords, entry.country),
translatedCountry
);
return {
...entry,
name: `${city}, ${translatedCountry}`,
country: translatedCountry,
keywords: keywordsWithCountries
};
});
// Provide a sorted copy for deterministic rendering.
const SORTED_TIMEZONE_DATA = TIMEZONE_DATA.slice().sort((a, b) => a.name.localeCompare(b.name));