feat: localize site and expand timezone data
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 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:
|
||||
|
||||
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.
|
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 });
|
||||
});
|
60
index.html
Normal file
60
index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<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>von Andreas Dueren (andreas.due.ren) · Open Source unter der <a href="LICENSE">MIT-Lizenz</a></p>
|
||||
</footer>
|
||||
|
||||
<script src="zones.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
470
styles.css
Normal file
470
styles.css
Normal 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(3.6rem, 10vw, 6.4rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.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
312
zones.js
Normal 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));
|
Reference in New Issue
Block a user