feat: localize site and expand timezone data

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

21
LICENSE Normal file
View 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
View File

@@ -0,0 +1,380 @@
const POPULAR_NAMES = [
'New York, Vereinigte Staaten',
'London, Vereinigtes Königreich',
'Los Angeles, Vereinigte Staaten',
'Tokyo, Japan',
'Sydney, Australien',
'Singapore, Singapur',
'Dubai, Vereinigte Arabische Emirate',
'São Paulo, Brasilien',
'Berlin, Deutschland',
'Mexico City, Mexiko'
];
const timezoneByName = new Map(SORTED_TIMEZONE_DATA.map((entry) => [entry.name, entry]));
function normalizeText(value) {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const localLabel = buildLocalLabel(localZone);
let selected = [
{
name: localLabel.name,
country: localLabel.country,
timeZone: localZone,
isLocal: true
}
];
const THEME_STORAGE_KEY = 'weltzeit:theme';
const localTimeEl = document.getElementById('local-time');
const localDateEl = document.getElementById('local-date');
const localZoneEl = document.getElementById('local-zone');
const searchInputEl = document.getElementById('search-input');
const searchResultsEl = document.getElementById('search-results');
const selectionListEl = document.getElementById('selection-list');
const resetButtonEl = document.getElementById('reset-button');
const themeToggleButton = document.getElementById('theme-toggle');
const themeToggleIcon = themeToggleButton?.querySelector('.theme-toggle-icon');
const themeToggleLabel = themeToggleButton?.querySelector('.theme-toggle-label');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
let updateTimer = null;
let hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_KEY));
function updateThemeToggleLabel(theme) {
if (!themeToggleButton || !themeToggleIcon || !themeToggleLabel) {
return;
}
if (theme === 'dark') {
themeToggleIcon.textContent = '☀︎';
themeToggleLabel.textContent = 'Heller Modus';
} else {
themeToggleIcon.textContent = '☾';
themeToggleLabel.textContent = 'Dunkler Modus';
}
}
function applyTheme(theme, { persist } = { persist: true }) {
const normalized = theme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', normalized);
updateThemeToggleLabel(normalized);
if (persist) {
window.localStorage.setItem(THEME_STORAGE_KEY, normalized);
}
}
function resolveInitialTheme() {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'light' || stored === 'dark') {
return stored;
}
return prefersDark.matches ? 'dark' : 'light';
}
function buildLocalLabel(timeZone) {
const formatter = Intl.DateTimeFormat(undefined, {
timeZone,
timeZoneName: 'longGeneric'
});
const parts = formatter.formatToParts(new Date());
const zoneName = parts.find((part) => part.type === 'timeZoneName')?.value ?? timeZone;
return {
name: zoneName,
country: '',
zoneName
};
}
function formatClockValue(date, timeZone) {
return new Intl.DateTimeFormat(undefined, {
timeZone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).format(date);
}
function formatDateValue(date, timeZone) {
return new Intl.DateTimeFormat(undefined, {
timeZone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
}
function pad(value) {
return String(Math.abs(value)).padStart(2, '0');
}
function getOffsetMinutes(date, timeZone) {
const withParts = new Intl.DateTimeFormat('en-CA', {
timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date);
const partValues = Object.fromEntries(
withParts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])
);
const zonedUTC = Date.UTC(
Number(partValues.year),
Number(partValues.month) - 1,
Number(partValues.day),
Number(partValues.hour),
Number(partValues.minute),
Number(partValues.second)
);
return Math.round((zonedUTC - date.getTime()) / 60000);
}
function formatOffsetLabel(offsetMinutes) {
if (offsetMinutes === 0) {
return 'Entspricht der lokalen Zeit';
}
const sign = offsetMinutes > 0 ? '+' : '-';
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const minutes = Math.abs(offsetMinutes) % 60;
return `${sign}${pad(hours)}:${pad(minutes)}`;
}
function updateLocalClock() {
const now = new Date();
localTimeEl.textContent = formatClockValue(now, localZone);
localDateEl.textContent = formatDateValue(now, localZone);
localZoneEl.textContent = localLabel.zoneName || localZone;
}
function renderSelections() {
selectionListEl.innerHTML = '';
const now = new Date();
const baseOffset = getOffsetMinutes(now, selected[0].timeZone);
selected.forEach((entry, index) => {
const listItem = document.createElement('li');
listItem.className = 'selection-item';
if (index === 0) {
listItem.classList.add('highlight');
}
const locationContainer = document.createElement('div');
const nameEl = document.createElement('div');
nameEl.className = 'location-name';
nameEl.textContent = entry.name;
const metaEl = document.createElement('div');
metaEl.className = 'location-meta';
metaEl.textContent = [entry.country, entry.timeZone].filter(Boolean).join(' • ');
locationContainer.appendChild(nameEl);
if (metaEl.textContent) {
locationContainer.appendChild(metaEl);
}
const timeEl = document.createElement('div');
timeEl.className = 'time-value';
timeEl.textContent = formatClockValue(now, entry.timeZone);
const offsetEl = document.createElement('div');
offsetEl.className = 'offset-value';
const relativeOffset = getOffsetMinutes(now, entry.timeZone) - baseOffset;
offsetEl.textContent = formatOffsetLabel(relativeOffset);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'remove-button';
removeButton.setAttribute('aria-label', `${entry.name} entfernen`);
removeButton.textContent = '×';
removeButton.addEventListener('click', () => {
removeSelection(entry.timeZone);
});
listItem.appendChild(locationContainer);
listItem.appendChild(timeEl);
listItem.appendChild(offsetEl);
if (!entry.isLocal) {
listItem.appendChild(removeButton);
} else {
const placeholder = document.createElement('div');
listItem.appendChild(placeholder);
}
selectionListEl.appendChild(listItem);
});
}
function removeSelection(timeZone) {
selected = selected.filter((entry) => !(entry.timeZone === timeZone && !entry.isLocal));
if (!selected.some((entry) => entry.isLocal)) {
selected.unshift({
name: localLabel.name,
country: localLabel.country,
timeZone: localZone,
isLocal: true
});
}
renderSelections();
}
function addSelection(entry) {
if (selected.some((item) => item.timeZone === entry.timeZone)) {
return;
}
selected.push({
name: entry.name,
country: entry.country,
timeZone: entry.timeZone
});
renderSelections();
}
function clearResults() {
searchResultsEl.innerHTML = '';
}
function renderSearchResults(query = '') {
const trimmed = query.trim().toLowerCase();
const normalizedQuery = normalizeText(trimmed);
let matches;
if (!trimmed) {
matches = POPULAR_NAMES.map((name) => timezoneByName.get(name)).filter(Boolean);
} else {
matches = SORTED_TIMEZONE_DATA.filter((entry) => {
const haystack = [
entry.name,
entry.country,
entry.timeZone,
...(entry.keywords || [])
].join(' ');
const lowerHaystack = haystack.toLowerCase();
const normalizedHaystack = normalizeText(haystack);
return lowerHaystack.includes(trimmed) || normalizedHaystack.includes(normalizedQuery);
}).slice(0, 20);
}
clearResults();
matches.forEach((entry) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'search-result-item';
const alreadySelected = selected.some((current) => current.timeZone === entry.timeZone);
if (alreadySelected) {
item.disabled = true;
item.classList.add('is-selected');
}
item.addEventListener('click', () => {
if (item.disabled) {
return;
}
addSelection(entry);
searchInputEl.value = '';
renderSearchResults('');
searchInputEl.focus();
});
const name = document.createElement('div');
name.className = 'result-name';
name.textContent = entry.name;
const zone = document.createElement('div');
zone.className = 'result-zone';
zone.textContent = `${entry.timeZone}${entry.country}`;
item.appendChild(name);
item.appendChild(zone);
searchResultsEl.appendChild(item);
});
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'search-result-item';
empty.textContent = 'Keine Treffer. Probiere eine andere Stadt, ein anderes Land oder eine andere Zeitzone.';
empty.style.cursor = 'default';
searchResultsEl.appendChild(empty);
}
}
function startClock() {
updateLocalClock();
renderSelections();
if (updateTimer) {
clearInterval(updateTimer);
}
updateTimer = setInterval(() => {
updateLocalClock();
renderSelections();
}, 1000);
}
searchInputEl.addEventListener('input', (event) => {
renderSearchResults(event.target.value);
});
searchInputEl.addEventListener('focus', () => {
renderSearchResults(searchInputEl.value);
});
resetButtonEl.addEventListener('click', () => {
selected = [
{
name: localLabel.name,
country: localLabel.country,
timeZone: localZone,
isLocal: true
}
];
renderSelections();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
updateLocalClock();
renderSelections();
}
});
const initialTheme = resolveInitialTheme();
applyTheme(initialTheme, { persist: false });
hasExplicitThemePreference = Boolean(window.localStorage.getItem(THEME_STORAGE_KEY));
window.addEventListener('load', () => {
startClock();
renderSearchResults('');
});
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || resolveInitialTheme();
const next = current === 'dark' ? 'light' : 'dark';
hasExplicitThemePreference = true;
applyTheme(next, { persist: true });
});
}
prefersDark.addEventListener('change', (event) => {
if (hasExplicitThemePreference) {
return;
}
applyTheme(event.matches ? 'dark' : 'light', { persist: false });
});

60
index.html Normal file
View 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
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(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
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));