Add files via upload
This commit is contained in:
1 parent
de8640d801
commit
90ffa5b0ca
6 files changed
+463
-91
No files matched your search
@@ -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 })}`);
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -59,7 +59,7 @@ const App = (() => {
|
||||
const items = loadRecentSearches();
|
||||
container.innerHTML = '';
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="recent-empty">Noch keine Suchen.</div>';
|
||||
container.innerHTML = `<div class="recent-empty">${escapeHtml(I18N.t('recent.empty'))}</div>`;
|
||||
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 = (() => {
|
||||
<span class="favorite-card__star">★</span>
|
||||
<span>
|
||||
<strong>${escapeHtml(fav.name)}</strong>
|
||||
<small class="favorite-card__next" data-next-departure>Lade nächste Abfahrt…</small>
|
||||
<small class="favorite-card__next" data-next-departure>${escapeHtml(I18N.t('favorites.loadingNext'))}</small>
|
||||
<small class="favorite-card__disruption" data-disruption hidden></small>
|
||||
</span>
|
||||
<span class="favorite-card__arrow">→</span>
|
||||
@@ -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 = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
|
||||
el.innerHTML = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} ${escapeHtml(I18N.t('favorites.cancelledShort'))}</span>`;
|
||||
} 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 = '<li class="search-result-heading">Zuletzt gesucht</li>';
|
||||
results.innerHTML = `<li class="search-result-heading">${escapeHtml(I18N.t('search.recentSearches'))}</li>`;
|
||||
renderSearchResults(results, recents, (station) => {
|
||||
selectStation(station);
|
||||
input.value = '';
|
||||
@@ -449,7 +449,7 @@ const App = (() => {
|
||||
if (!append) container.innerHTML = '';
|
||||
|
||||
if (locations.length === 0) {
|
||||
container.innerHTML = '<li class="search-result search-result--empty">Keine Treffer</li>';
|
||||
container.innerHTML = `<li class="search-result search-result--empty">${escapeHtml(I18N.t('search.noResults'))}</li>`;
|
||||
container.hidden = false;
|
||||
return;
|
||||
}
|
||||
@@ -561,7 +561,7 @@ const App = (() => {
|
||||
const source = currentStop ? currentStop.source : null;
|
||||
|
||||
if (departures.length === 0) {
|
||||
board.innerHTML = '<div class="board-empty">Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.</div>';
|
||||
board.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('departures.noResultsForModes'))}</div>`;
|
||||
lastRenderedSource = source;
|
||||
return;
|
||||
}
|
||||
@@ -589,19 +589,19 @@ const App = (() => {
|
||||
row.style.setProperty('--row-index', index);
|
||||
row.innerHTML = `
|
||||
<div class="flap-field flap-field--line">
|
||||
<span class="flap-label">Linie</span>
|
||||
<span class="flap-label">${escapeHtml(I18N.t('departures.line'))}</span>
|
||||
<span class="flap-cell flap-cell--line" data-field="line"></span>
|
||||
</div>
|
||||
<div class="flap-field flap-field--direction">
|
||||
<span class="flap-label">Ziel</span>
|
||||
<span class="flap-label">${escapeHtml(I18N.t('departures.direction'))}</span>
|
||||
<span class="flap-cell flap-cell--direction" data-field="direction"></span>
|
||||
</div>
|
||||
<div class="flap-field flap-field--platform">
|
||||
<span class="flap-label">Gleis</span>
|
||||
<span class="flap-label">${escapeHtml(I18N.t('departures.platform'))}</span>
|
||||
<span class="flap-cell flap-cell--platform" data-field="platform"></span>
|
||||
</div>
|
||||
<div class="flap-field flap-field--time">
|
||||
<span class="flap-label">Abfahrt</span>
|
||||
<span class="flap-label">${escapeHtml(I18N.t('departures.time'))}</span>
|
||||
<span class="flap-cell flap-cell--time" data-field="time"></span>
|
||||
</div>
|
||||
`;
|
||||
@@ -664,8 +664,8 @@ const App = (() => {
|
||||
platformCell.dataset.changedFrom = plannedPlatform;
|
||||
platformCell.classList.add('flap-cell--platform-changed');
|
||||
platformCell.innerHTML = `
|
||||
<span class="platform-changed-flag">GLEIS ${escapeHtml(newPlatform)}</span>
|
||||
<span class="platform-changed-original">statt ${escapeHtml(plannedPlatform)}</span>
|
||||
<span class="platform-changed-flag">${escapeHtml(I18N.t('departures.platformChanged', { n: newPlatform }))}</span>
|
||||
<span class="platform-changed-original">${escapeHtml(I18N.t('departures.platformInstead', { n: plannedPlatform }))}</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
@@ -687,8 +687,8 @@ const App = (() => {
|
||||
timeCell.dataset.cancelledPlanned = plannedTime;
|
||||
timeCell.classList.add('flap-cell--time-cancelled');
|
||||
timeCell.innerHTML = `
|
||||
<span class="cancelled-flag">FÄLLT AUS</span>
|
||||
<span class="cancelled-original">urspr. ${plannedTime}</span>
|
||||
<span class="cancelled-flag">${escapeHtml(I18N.t('departures.cancelled'))}</span>
|
||||
<span class="cancelled-original">${escapeHtml(I18N.t('departures.plannedShort'))} ${escapeHtml(plannedTime)}</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
@@ -979,11 +979,11 @@ const App = (() => {
|
||||
const shareRow = document.getElementById('journey-share-row');
|
||||
|
||||
if (!journeyFrom || !journeyTo) {
|
||||
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
|
||||
resultsEl.innerHTML = `<div class="board-error">${escapeHtml(I18N.t('journey.selectFromResults'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = '<div class="board-empty">Suche Verbindungen…</div>';
|
||||
resultsEl.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('journey.searching'))}</div>`;
|
||||
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 = '<div class="board-empty">Keine Verbindungen gefunden.</div>';
|
||||
container.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('journey.noResults'))}</div>`;
|
||||
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();
|
||||
|
||||
+297
@@ -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 };
|
||||
})();
|
||||
+34
-2
@@ -32,6 +32,7 @@ const TripModal = (() => {
|
||||
</div>
|
||||
<button class="trip-modal__close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="trip-modal__stats" id="trip-modal-stats" hidden></div>
|
||||
<div class="trip-modal__map" id="trip-modal-map"></div>
|
||||
<div class="trip-modal__body" id="trip-modal-body">
|
||||
<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>
|
||||
@@ -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 = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user