diff --git a/index.html b/index.html index 28ebd5d..bf55e6a 100644 --- a/index.html +++ b/index.html @@ -24,43 +24,43 @@ - +
LIVE · VBB
-

Was fährt jetzt?

-

Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.

+

Was fährt jetzt?

+

Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.

@@ -68,152 +68,160 @@
-
ℹ Live-Daten von VBB · Angaben ohne Gewähr
+
ℹ Live-Daten von VBB · Angaben ohne Gewähr
+ diff --git a/js/api.js b/js/api.js index e723eca..ad5b0f1 100644 --- a/js/api.js +++ b/js/api.js @@ -140,5 +140,11 @@ const API = (() => { getSummaryDepartures(stations) { return postJson('/summary/departures', { stations }); }, + + // Verspätungsstatistik pro Linie (siehe backend/src/utils/delayStats.js) - + // "sufficientData: false" bedeutet: noch zu wenige Datenpunkte gesammelt. + getLineDelayStats(line, days = 14) { + return request(`/stats/delays/${encodeURIComponent(line)}?${qs({ days })}`); + }, }; })(); \ No newline at end of file diff --git a/js/app.js b/js/app.js index 940bd17..15af4a5 100644 --- a/js/app.js +++ b/js/app.js @@ -59,7 +59,7 @@ const App = (() => { const items = loadRecentSearches(); container.innerHTML = ''; if (!items.length) { - container.innerHTML = '
Noch keine Suchen.
'; + container.innerHTML = `
${escapeHtml(I18N.t('recent.empty'))}
`; return; } items.forEach((item) => { @@ -75,7 +75,7 @@ const App = (() => { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'recent-item__remove'; - remove.setAttribute('aria-label', `${item.name} aus Verlauf entfernen`); + remove.setAttribute('aria-label', I18N.t('recent.remove', { name: item.name })); remove.textContent = '×'; remove.addEventListener('click', (e) => { e.stopPropagation(); @@ -150,7 +150,7 @@ const App = (() => { ★ ${escapeHtml(fav.name)} - Lade nächste Abfahrt… + ${escapeHtml(I18N.t('favorites.loadingNext'))} → @@ -252,14 +252,14 @@ const App = (() => { if (el) { if (r.error || !r.nextDeparture) { - el.textContent = 'Keine Abfahrt gefunden'; + el.textContent = I18N.t('favorites.noDeparture'); } else { const dep = r.nextDeparture; const minutesUntil = dep.when ? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000)) : null; if (dep.cancelled) { - el.innerHTML = `${escapeHtml(dep.line || '')} fällt aus`; + el.innerHTML = `${escapeHtml(dep.line || '')} ${escapeHtml(I18N.t('favorites.cancelledShort'))}`; } else { - el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`; + el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' ' + I18N.t('favorites.minutesShort') : ''}`; } } } @@ -320,15 +320,15 @@ const App = (() => { } btn.hidden = false; if (Notification.permission === 'granted') { - btn.textContent = '🔔 Benachrichtigungen aktiv'; + btn.textContent = `🔔 ${I18N.t('favorites.notifyActive')}`; btn.classList.add('is-active'); btn.disabled = true; } else if (Notification.permission === 'denied') { - btn.textContent = '🔕 Benachrichtigungen blockiert'; + btn.textContent = `🔕 ${I18N.t('favorites.notifyBlocked')}`; btn.classList.remove('is-active'); btn.disabled = true; } else { - btn.textContent = '🔔 Störungsbenachrichtigungen aktivieren'; + btn.textContent = `🔔 ${I18N.t('favorites.enableNotify')}`; btn.classList.remove('is-active'); btn.disabled = false; } @@ -356,7 +356,7 @@ const App = (() => { } btn.hidden = false; const active = isFavorite(currentStop.id); - btn.textContent = active ? '★ Favorit' : '☆ Favorit'; + btn.textContent = `${active ? '★' : '☆'} ${I18N.t('favorites.favorite')}`; btn.classList.toggle('is-active', active); } @@ -389,7 +389,7 @@ const App = (() => { results.innerHTML = ''; return; } - results.innerHTML = '
  • Zuletzt gesucht
  • '; + results.innerHTML = `
  • ${escapeHtml(I18N.t('search.recentSearches'))}
  • `; renderSearchResults(results, recents, (station) => { selectStation(station); input.value = ''; @@ -449,7 +449,7 @@ const App = (() => { if (!append) container.innerHTML = ''; if (locations.length === 0) { - container.innerHTML = '
  • Keine Treffer
  • '; + container.innerHTML = `
  • ${escapeHtml(I18N.t('search.noResults'))}
  • `; container.hidden = false; return; } @@ -561,7 +561,7 @@ const App = (() => { const source = currentStop ? currentStop.source : null; if (departures.length === 0) { - board.innerHTML = '
    Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.
    '; + board.innerHTML = `
    ${escapeHtml(I18N.t('departures.noResultsForModes'))}
    `; lastRenderedSource = source; return; } @@ -589,19 +589,19 @@ const App = (() => { row.style.setProperty('--row-index', index); row.innerHTML = `
    - Linie + ${escapeHtml(I18N.t('departures.line'))}
    - Ziel + ${escapeHtml(I18N.t('departures.direction'))}
    - Gleis + ${escapeHtml(I18N.t('departures.platform'))}
    - Abfahrt + ${escapeHtml(I18N.t('departures.time'))}
    `; @@ -664,8 +664,8 @@ const App = (() => { platformCell.dataset.changedFrom = plannedPlatform; platformCell.classList.add('flap-cell--platform-changed'); platformCell.innerHTML = ` - GLEIS ${escapeHtml(newPlatform)} - statt ${escapeHtml(plannedPlatform)} + ${escapeHtml(I18N.t('departures.platformChanged', { n: newPlatform }))} + ${escapeHtml(I18N.t('departures.platformInstead', { n: plannedPlatform }))} `; } } else { @@ -687,8 +687,8 @@ const App = (() => { timeCell.dataset.cancelledPlanned = plannedTime; timeCell.classList.add('flap-cell--time-cancelled'); timeCell.innerHTML = ` - FÄLLT AUS - urspr. ${plannedTime} + ${escapeHtml(I18N.t('departures.cancelled'))} + ${escapeHtml(I18N.t('departures.plannedShort'))} ${escapeHtml(plannedTime)} `; } } else { @@ -979,11 +979,11 @@ const App = (() => { const shareRow = document.getElementById('journey-share-row'); if (!journeyFrom || !journeyTo) { - resultsEl.innerHTML = '
    Bitte Start und Ziel aus der Liste auswählen.
    '; + resultsEl.innerHTML = `
    ${escapeHtml(I18N.t('journey.selectFromResults'))}
    `; return; } - resultsEl.innerHTML = '
    Suche Verbindungen…
    '; + resultsEl.innerHTML = `
    ${escapeHtml(I18N.t('journey.searching'))}
    `; if (shareRow) shareRow.hidden = true; const whenInput = document.getElementById('journey-when').value; @@ -1142,7 +1142,7 @@ const App = (() => { container.innerHTML = ''; if (journeys.length === 0) { - container.innerHTML = '
    Keine Verbindungen gefunden.
    '; + container.innerHTML = `
    ${escapeHtml(I18N.t('journey.noResults'))}
    `; return; } @@ -1261,6 +1261,19 @@ const App = (() => { } function init() { + I18N.init(); + I18N.setOnLangChange(() => { + // [data-i18n]-Elemente übernimmt I18N.applyStaticTranslations() bereits + // selbst - hier nur die dynamisch (per JS-Template) erzeugten Bereiche + // neu aufbauen, die Sprachwechsel sonst nicht mitbekommen würden. + lastRenderedSource = null; + if (currentStop) loadDepartures(); + renderFavoriteChips(); + }); + document.querySelectorAll('.lang-switch__btn').forEach((btn) => { + btn.addEventListener('click', () => I18N.setLang(btn.dataset.lang)); + }); + initSearch(); initTabs(); initJourneyPlanner(); diff --git a/js/i18n.js b/js/i18n.js new file mode 100644 index 0000000..aee7056 --- /dev/null +++ b/js/i18n.js @@ -0,0 +1,297 @@ +// Mehrsprachigkeit: einfaches, erweiterbares Key-Value-Wörterbuch statt einer +// externen i18n-Library (die App ist klein genug, dass sich das nicht lohnt). +// Deutsch ist immer die Fallback-Sprache, falls ein Key in einer anderen +// Sprache fehlt - so kann man neue Sprachen nach und nach vervollständigen, +// ohne dass währenddessen Keys komplett leer/undefined angezeigt werden. +// +// Deckt aktuell ab: die komplette statische Oberfläche (Navigation, +// Überschriften, Buttons, Platzhalter) sowie die am häufigsten sichtbaren +// dynamisch erzeugten Texte (Abfahrtstafel-Beschriftungen, Favoriten-Karten, +// Formular-Rückmeldungen). Tiefer verschachtelte Bereiche (Fahrt-Detail-Modal, +// Fahrtalarm-Overlay, Live-Karten-Popups) sowie Störungstexte direkt von VBB +// bleiben bewusst auf Deutsch, da VBB dafür keine englischen Texte liefert. + +const I18N = (() => { + const STORAGE_KEY = 'oepnv-navi:db-lang'; + + const translations = { + de: { + 'search.placeholder': 'Haltestelle, Ort oder Ziel suchen…', + 'search.clear': 'Suche leeren', + 'nav.favorites': 'Favoriten', + 'nav.departures': 'Abfahrten', + 'nav.route': 'Route', + 'nav.map': 'Karte', + 'nav.more': 'Mehr', + 'common.optional': 'OPTIONAL', + 'common.showAll': 'Alle anzeigen', + 'common.live': 'LIVE', + 'common.current': 'AKTUELL', + 'common.clear': 'Leeren', + 'common.footer': 'ℹ Live-Daten von VBB · Angaben ohne Gewähr', + 'welcome.title': 'Was fährt jetzt?', + 'welcome.text': 'Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.', + 'welcome.nearbyTitle': 'In meiner Nähe', + 'welcome.nearbyText': 'Haltestellen im Umkreis finden', + 'welcome.routeTitle': 'Route planen', + 'welcome.routeText': 'Von A nach B mit Bus & Bahn', + 'nearby.homeTitle': 'Haltestellen in der Nähe', + 'nearby.eyebrow': 'STANDORT', + 'nearby.title': 'In der Nähe', + 'nearby.text': 'Finde Haltestellen rund um deinen aktuellen Standort.', + 'nearby.locate': 'Meinen Standort verwenden', + 'nearby.emptyState': 'Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.', + 'departures.eyebrow': 'LIVE-ABFAHRTEN', + 'departures.noStation': 'Keine Haltestelle ausgewählt', + 'departures.refreshInfo': 'Aktualisierung alle 30 Sekunden', + 'departures.emptyState': 'Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.', + 'departures.line': 'Linie', + 'departures.direction': 'Ziel', + 'departures.platform': 'Gleis', + 'departures.time': 'Abfahrt', + 'departures.cancelled': 'FÄLLT AUS', + 'departures.plannedShort': 'urspr.', + 'departures.platformChanged': 'GLEIS {n}', + 'departures.platformInstead': 'statt {n}', + 'departures.noResultsForModes': 'Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.', + 'favorites.favorite': 'Favorit', + 'favorites.eyebrow': 'SCHNELLZUGRIFF', + 'favorites.title': 'Meine Haltestellen', + 'favorites.text': 'Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.', + 'favorites.enableNotify': 'Störungsbenachrichtigungen aktivieren', + 'favorites.notifyActive': 'Benachrichtigungen aktiv', + 'favorites.notifyBlocked': 'Benachrichtigungen blockiert', + 'favorites.emptyHint': 'Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.', + 'favorites.loadingNext': 'Lade nächste Abfahrt…', + 'favorites.noDeparture': 'Keine Abfahrt gefunden', + 'favorites.cancelledShort': 'fällt aus', + 'favorites.minutesShort': 'min', + 'map.title': 'Karte', + 'map.text': 'Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.', + 'map.locate': 'Mein Standort', + 'map.following': 'Verfolge Linie', + 'map.live': 'live', + 'map.showAllLines': 'Alle Linien anzeigen', + 'journey.eyebrow': 'VERBINDUNG SUCHEN', + 'journey.title': 'Wohin möchtest du?', + 'journey.text': 'Starte mit deinem Standort oder suche einen Ausgangspunkt.', + 'journey.fromPlaceholder': 'Mein Standort oder Start…', + 'journey.toPlaceholder': 'Wohin?', + 'journey.useLocation': 'Meinen Standort verwenden', + 'journey.now': 'Jetzt', + 'journey.departureAt': 'Abfahrt um', + 'journey.arrivalAt': 'Ankunft um', + 'journey.wheelchair': 'Nur rollstuhlgerechte Verbindungen', + 'journey.transferSlack': 'Umstiegszeit-Puffer', + 'journey.transferSlackDefault': 'Standard', + 'journey.transferSlack0': '0 min (knapp)', + 'journey.transferSlack15': '15 min (sicher)', + 'journey.submit': 'Verbindungen suchen', + 'journey.sourceNote': '🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)', + 'journey.share': 'Route teilen', + 'journey.selectFromResults': 'Bitte Start und Ziel aus der Liste auswählen.', + 'journey.searching': 'Suche Verbindungen…', + 'journey.noResults': 'Keine Verbindungen gefunden.', + 'disruptions.title': 'Störungen', + 'disruptions.text': 'Ortsbezogene und netzweite Hinweise.', + 'disruptions.nearbyTitle': 'In deiner Nähe', + 'disruptions.useLocation': 'Standort verwenden', + 'disruptions.nearbyEmpty': 'Nutze deinen Standort für ortsbezogene Störungen.', + 'disruptions.networkTitle': 'Netzweit', + 'disruptions.loading': 'Störungsmeldungen werden geladen…', + 'fares.eyebrow': 'PREISE', + 'fares.title': 'Tickets', + 'fares.text': 'Übliche VBB-Fahrpreise auf einen Blick.', + 'fares.disclaimer': 'Angaben ohne Gewähr – Preise können sich ändern.', + 'fares.loading': 'Lade Fahrpreise…', + 'fares.buyTitle': 'Tickets kaufen', + 'more.eyebrow': 'WEITERE FUNKTIONEN', + 'more.text': 'Alles, was nicht bei jeder Fahrt gebraucht wird.', + 'more.favoritesText': 'Deine gespeicherten Haltestellen', + 'more.nearbyText': 'Haltestellen in deiner Umgebung', + 'more.disruptionsText': 'Ortsbezogen & netzweit', + 'more.faresText': 'Preise & wo kaufen', + 'more.historyEyebrow': 'VERLAUF', + 'more.historyTitle': 'Zuletzt gesucht', + 'more.langEyebrow': 'SPRACHE / LANGUAGE', + 'more.langTitle': 'Sprache', + 'search.noResults': 'Keine Treffer', + 'search.recentSearches': 'Zuletzt gesucht', + 'recent.empty': 'Noch keine Suchen.', + 'recent.remove': '{name} aus Verlauf entfernen', + }, + en: { + 'search.placeholder': 'Search for a stop, place or destination…', + 'search.clear': 'Clear search', + 'nav.favorites': 'Favorites', + 'nav.departures': 'Departures', + 'nav.route': 'Route', + 'nav.map': 'Map', + 'nav.more': 'More', + 'common.optional': 'OPTIONAL', + 'common.showAll': 'Show all', + 'common.live': 'LIVE', + 'common.current': 'CURRENT', + 'common.clear': 'Clear', + 'common.footer': 'ℹ Live data from VBB · No guarantee of accuracy', + 'welcome.title': 'What\u2019s leaving now?', + 'welcome.text': 'Search for a stop or use your location to see the next departures.', + 'welcome.nearbyTitle': 'Nearby', + 'welcome.nearbyText': 'Find stops around you', + 'welcome.routeTitle': 'Plan a route', + 'welcome.routeText': 'From A to B by bus & train', + 'nearby.homeTitle': 'Stops nearby', + 'nearby.eyebrow': 'LOCATION', + 'nearby.title': 'Nearby', + 'nearby.text': 'Find stops around your current location.', + 'nearby.locate': 'Use my location', + 'nearby.emptyState': 'Use your location to find stops nearby.', + 'departures.eyebrow': 'LIVE DEPARTURES', + 'departures.noStation': 'No stop selected', + 'departures.refreshInfo': 'Updates every 30 seconds', + 'departures.emptyState': 'Search for a stop above to see live departures.', + 'departures.line': 'Line', + 'departures.direction': 'Towards', + 'departures.platform': 'Platform', + 'departures.time': 'Departure', + 'departures.cancelled': 'CANCELLED', + 'departures.plannedShort': 'orig.', + 'departures.platformChanged': 'PLATFORM {n}', + 'departures.platformInstead': 'instead of {n}', + 'departures.noResultsForModes': 'No departures for the selected transport modes in the next 30 minutes.', + 'favorites.favorite': 'Favorite', + 'favorites.eyebrow': 'QUICK ACCESS', + 'favorites.title': 'My Stops', + 'favorites.text': 'Your saved stops with live departures and disruption notices.', + 'favorites.enableNotify': 'Enable disruption notifications', + 'favorites.notifyActive': 'Notifications active', + 'favorites.notifyBlocked': 'Notifications blocked', + 'favorites.emptyHint': 'No favorites yet - tap ☆ at a stop to pin it here.', + 'favorites.loadingNext': 'Loading next departure…', + 'favorites.noDeparture': 'No departure found', + 'favorites.cancelledShort': 'cancelled', + 'favorites.minutesShort': 'min', + 'map.title': 'Map', + 'map.text': 'Track vehicles, routes and your current trip live.', + 'map.locate': 'My location', + 'map.following': 'Tracking line', + 'map.live': 'live', + 'map.showAllLines': 'Show all lines', + 'journey.eyebrow': 'PLAN A JOURNEY', + 'journey.title': 'Where to?', + 'journey.text': 'Start with your location or search for a starting point.', + 'journey.fromPlaceholder': 'My location or start…', + 'journey.toPlaceholder': 'Where to?', + 'journey.useLocation': 'Use my location', + 'journey.now': 'Now', + 'journey.departureAt': 'Depart at', + 'journey.arrivalAt': 'Arrive by', + 'journey.wheelchair': 'Wheelchair-accessible connections only', + 'journey.transferSlack': 'Transfer buffer', + 'journey.transferSlackDefault': 'Default', + 'journey.transferSlack0': '0 min (tight)', + 'journey.transferSlack15': '15 min (safe)', + 'journey.submit': 'Search connections', + 'journey.sourceNote': '🛰 Journey planning via our self-hosted, open instance (VBB region)', + 'journey.share': 'Share route', + 'journey.selectFromResults': 'Please choose start and destination from the list.', + 'journey.searching': 'Searching for connections…', + 'journey.noResults': 'No connections found.', + 'disruptions.title': 'Disruptions', + 'disruptions.text': 'Location-based and network-wide notices.', + 'disruptions.nearbyTitle': 'Near you', + 'disruptions.useLocation': 'Use location', + 'disruptions.nearbyEmpty': 'Use your location for nearby disruptions.', + 'disruptions.networkTitle': 'Network-wide', + 'disruptions.loading': 'Loading disruption notices…', + 'fares.eyebrow': 'FARES', + 'fares.title': 'Tickets', + 'fares.text': 'Common VBB fares at a glance.', + 'fares.disclaimer': 'No guarantee of accuracy – prices may change.', + 'fares.loading': 'Loading fares…', + 'fares.buyTitle': 'Buy tickets', + 'more.eyebrow': 'MORE FEATURES', + 'more.text': 'Everything you don\u2019t need on every trip.', + 'more.favoritesText': 'Your saved stops', + 'more.nearbyText': 'Stops around you', + 'more.disruptionsText': 'Local & network-wide', + 'more.faresText': 'Prices & where to buy', + 'more.historyEyebrow': 'HISTORY', + 'more.historyTitle': 'Recently searched', + 'more.langEyebrow': 'SPRACHE / LANGUAGE', + 'more.langTitle': 'Language', + 'search.noResults': 'No results', + 'search.recentSearches': 'Recently searched', + 'recent.empty': 'No searches yet.', + 'recent.remove': 'Remove {name} from history', + }, + }; + + let lang = 'de'; + let onLangChange = null; + + function detectLang() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && translations[saved]) return saved; + } catch (_) { /* localStorage evtl. nicht verfügbar */ } + const browserLang = (navigator.language || 'de').slice(0, 2).toLowerCase(); + return translations[browserLang] ? browserLang : 'de'; + } + + function t(key, vars) { + let str = (translations[lang] && translations[lang][key]) || translations.de[key] || key; + if (vars) { + Object.keys(vars).forEach((k) => { + str = str.replace(`{${k}}`, vars[k]); + }); + } + return str; + } + + function applyStaticTranslations() { + document.querySelectorAll('[data-i18n]').forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + document.querySelectorAll('[data-i18n-aria-label]').forEach((el) => { + el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel)); + }); + document.querySelectorAll('[data-i18n-title]').forEach((el) => { + el.title = t(el.dataset.i18nTitle); + }); + document.querySelectorAll('.lang-switch__btn').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.lang === lang); + }); + } + + function setLang(newLang) { + if (!translations[newLang] || newLang === lang) return; + lang = newLang; + try { localStorage.setItem(STORAGE_KEY, newLang); } catch (_) { /* ignorieren */ } + document.documentElement.lang = newLang; + applyStaticTranslations(); + if (typeof onLangChange === 'function') onLangChange(newLang); + } + + // Wird von app.js gesetzt, um nach einem Sprachwechsel auch dynamisch + // erzeugte Inhalte (z.B. gerade sichtbare Abfahrtstafel) neu zu rendern - + // applyStaticTranslations() allein erreicht nur [data-i18n]-Elemente im DOM. + function setOnLangChange(fn) { + onLangChange = fn; + } + + function getLang() { + return lang; + } + + function init() { + lang = detectLang(); + document.documentElement.lang = lang; + applyStaticTranslations(); + } + + return { t, setLang, getLang, setOnLangChange, init }; +})(); diff --git a/js/tripmodal.js b/js/tripmodal.js index 12d5c4f..afcfb20 100644 --- a/js/tripmodal.js +++ b/js/tripmodal.js @@ -32,6 +32,7 @@ const TripModal = (() => { +
    Lade Fahrt-Details…
    @@ -75,6 +76,33 @@ const TripModal = (() => { return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } + // Verspätungsstatistik pro Linie - Datenbasis wächst erst über Zeit, auf + // einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen. + async function loadLineStats(line) { + const el = document.getElementById('trip-modal-stats'); + if (!el) return; + el.hidden = true; + if (!line) return; + + try { + const stats = await API.getLineDelayStats(line); + if (!stats || !stats.sufficientData) return; // still hidden - keine Aussage besser als eine unsichere + + const avgMin = Math.round(stats.avgDelaySeconds / 60); + const parts = []; + parts.push(avgMin > 0 ? `Ø +${avgMin} Min` : 'Ø pünktlich'); + parts.push(`${stats.pctDelayed5plus}% ≥5 Min verspätet`); + if (stats.pctCancelled > 0) parts.push(`${stats.pctCancelled}% Ausfälle`); + + el.textContent = `📊 ${parts.join(' · ')} (${stats.sampleCount} Fahrten, ${stats.days} Tage)`; + el.hidden = false; + } catch (err) { + // Statistik ist ein "nice to have" - bei Fehlern einfach ausblenden, + // ohne die eigentliche Fahrt-Detail-Ansicht zu stören. + console.error('Verspätungsstatistik-Fehler:', err.message); + } + } + function getAlarmOffset() { return parseInt(localStorage.getItem(ALARM_OFFSET_KEY), 10) || 1; } @@ -183,9 +211,11 @@ const TripModal = (() => { currentTripId = tripId; currentTrip = null; + const statsEl = document.getElementById('trip-modal-stats'); + if (statsEl) { statsEl.hidden = true; } + document.getElementById('trip-modal-line').textContent = '…'; - document.getElementById('trip-modal-line').style.background = '#8E99A6'; - document.getElementById('trip-modal-direction').textContent = ''; + document.getElementById('trip-modal-line').style.background = '#8E99A6'; document.getElementById('trip-modal-direction').textContent = ''; document.getElementById('trip-modal-meta').textContent = ''; document.getElementById('trip-modal-body').innerHTML = '
    Lade Fahrt-Details…
    '; @@ -209,6 +239,8 @@ const TripModal = (() => { ? 'Fahrt fällt aus' : `${trip.origin || ''} → ${trip.destination || ''}`; + loadLineStats(trip.line); + // Mini-Karte mit dem Streckenverlauf zeichnen if (miniMap) { miniMap.remove(); miniMap = null; } if (trip.polyline) { diff --git a/styles.css b/styles.css index b1ee731..70819d5 100644 --- a/styles.css +++ b/styles.css @@ -866,7 +866,7 @@ button:active { transform: translateY(1px); } border-bottom: 1px solid var(--line); } .tab-button { padding: 14px 18px; display: inline-flex; gap: 8px; align-items: center; } -.tab-button span { filter: grayscale(.35); } +.tab-button__icon { filter: grayscale(.35); } .app-main { max-width: 920px; padding: 30px 20px 50px; } @@ -1072,6 +1072,14 @@ button:active { transform: translateY(1px); } .recent-item__remove:hover { background: var(--panel-raised); color: var(--signal-red); } .recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; } +.lang-switch { display: flex; gap: 8px; } +.lang-switch__btn { + background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line); + border-radius: 999px; padding: 8px 16px; font-size: 13px; +} +.lang-switch__btn:hover { border-color: var(--signal-amber); } +.lang-switch__btn.is-active { color: var(--signal-amber); border-color: var(--signal-amber); background: rgba(245,166,35,.08); } + .search-result-heading { padding: 8px 12px 4px; color: var(--ink-muted); @@ -1174,6 +1182,14 @@ button:active { transform: translateY(1px); } border-color: var(--signal-amber); } +.trip-modal__stats { + padding: 8px 18px; + font-size: 12px; + color: var(--ink-muted); + background: var(--panel-raised); + border-bottom: 1px solid var(--line); +} + .trip-modal__map { height: 220px; border-bottom: 1px solid var(--line);