From aaa876be83fb3b705564522a5e919b1b4bc51a78 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 13 Oct 2025 11:25:20 -0600 Subject: [PATCH] feat: localize site and expand timezone data --- LICENSE | 21 +++ app.js | 380 +++++++++++++++++++++++++++++++++++++++++++ index.html | 60 +++++++ styles.css | 470 +++++++++++++++++++++++++++++++++++++++++++++++++++++ zones.js | 312 +++++++++++++++++++++++++++++++++++ 5 files changed, 1243 insertions(+) create mode 100644 LICENSE create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css create mode 100644 zones.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..278982d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..a2e5fe4 --- /dev/null +++ b/app.js @@ -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 }); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..1937bac --- /dev/null +++ b/index.html @@ -0,0 +1,60 @@ + + + + + + Weltze.it — Alle Zeitzonen im Blick + + + + + + +
+
+

Finde eine Stadt oder Zeitzone

+ + +
+
+ Tipp: Füge mehrere Orte hinzu, um Abweichungen direkt zu vergleichen. +
+
+ +
+
+

Ausgewählte Orte

+ +
+
+
Ort
+
Aktuelle Zeit
+
Abweichung zur lokalen Zeit
+
+
    +
    +
    + + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..da96959 --- /dev/null +++ b/styles.css @@ -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; + } +} diff --git a/zones.js b/zones.js new file mode 100644 index 0000000..4a33b7c --- /dev/null +++ b/zones.js @@ -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));