@@ -693,6 +719,20 @@
return;
}
+ // Aktiven Verkehrsmittel-Filter anwenden
+ const departures = (typeof applyDepartureFilter === 'function')
+ ? applyDepartureFilter(fullDepartures) : fullDepartures;
+
+ if (departures.length === 0) {
+ departuresContainer.innerHTML = `
+
+ `;
+ return;
+ }
+
const now = new Date();
const stationInfoHTML = `
@@ -746,7 +786,14 @@
`;
}).join('');
- departuresContainer.innerHTML = stationInfoHTML + departuresHTML;
+ // "Mehr laden": erhöht n (max. 60) und lädt neu - so bleibt die
+ // erweiterte Liste auch beim Auto-Refresh erhalten
+ const loadMoreHTML = departuresCount < 60 ? `
+
+ ⏬ Mehr Abfahrten laden
+ ` : '';
+
+ departuresContainer.innerHTML = stationInfoHTML + departuresHTML + loadMoreHTML;
// Event-Listener für Departure-Items
departuresContainer.querySelectorAll('.departure-item').forEach(item => {
diff --git a/js/extras.js b/js/extras.js
index 41f17dd..bc369af 100644
--- a/js/extras.js
+++ b/js/extras.js
@@ -101,25 +101,21 @@ document.addEventListener('click', (e) => {
}
});
+// Reine Filterfunktion - wird von displayDepartures (app.js) beim
+// Rendern angewendet. Der Filter-Zustand lebt hier.
+function applyDepartureFilter(departures) {
+ if (activeDepartureFilters.has('all')) return departures;
+ return departures.filter(dep => {
+ const product = dep.line?.product?.toLowerCase() || '';
+ return activeDepartureFilters.has(product);
+ });
+}
+
+// Filter geändert -> mit der vollen Liste neu rendern
+// (displayDepartures filtert selbst; so geht nie Datenbestand verloren)
function filterDepartures() {
if (!allDepartures || allDepartures.length === 0) return;
-
- let filtered = allDepartures;
-
- if (!activeDepartureFilters.has('all')) {
- filtered = allDepartures.filter(dep => {
- const product = dep.line?.product?.toLowerCase() || '';
- return activeDepartureFilters.has(product);
- });
- }
-
- // Container neu rendern
- const container = document.getElementById('departuresContainer');
- if (filtered.length === 0) {
- container.innerHTML = '
Keine Abfahrten mit diesem Filter
';
- } else {
- displayDepartures(filtered);
- }
+ displayDepartures(allDepartures);
}
// ==========================================
@@ -167,34 +163,6 @@ function getActiveTransportModes() {
}
-// ==========================================
-// PATCHES FÜR FILTER-INTEGRATION
-// ==========================================
-
-// Original loadDepartures überschreiben
-const originalLoadDepartures = loadDepartures;
-loadDepartures = async function() {
- await originalLoadDepartures();
-
- // Filter-UI einblenden wenn Abfahrten geladen
- const filtersEl = document.getElementById('departureFilters');
- if (filtersEl && allDepartures.length > 0) {
- filtersEl.style.display = 'flex';
- }
-};
-
-// displayDepartures erweitern um allDepartures zu speichern
-const originalDisplayDepartures = displayDepartures;
-displayDepartures = function(departures) {
- // Speichere alle Abfahrten für Filter
- if (!activeDepartureFilters || activeDepartureFilters.has('all')) {
- allDepartures = departures;
- }
-
- // Normale Anzeige
- originalDisplayDepartures(departures);
-};
-
// HINWEIS: Der frühere "searchJourneys"-Patch wurde entfernt.
// Die Funktion existierte nie und verursachte einen ReferenceError,
// der das restliche Script abgebrochen hat.
@@ -440,6 +408,23 @@ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
// APP-START: GESPEICHERTEN ZUSTAND WIEDERHERSTELLEN
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
+ // Versionsnummer überall aus der EINEN Quelle (APP_VERSION) befüllen
+ const setText = (id, text) => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+ };
+ setText('homeVersion', 'v' + APP_VERSION);
+ setText('homeDevVersion', 'v' + APP_VERSION);
+ setText('devVersion', APP_VERSION);
+ setText('devRelease', APP_RELEASE_DATE);
+ setText('devCacheName', 'vbb-status-v' + APP_VERSION);
+
+ // Entwickler-Karte auf der Startseite -> Entwickler-View
+ const devCard = document.getElementById('homeDevCard');
+ if (devCard) {
+ devCard.addEventListener('click', () => switchView('developer'));
+ }
+
// Favoriten auf dem Home-Screen anzeigen
renderFavorites();
@@ -466,8 +451,27 @@ document.addEventListener('DOMContentLoaded', () => {
checkJourneySearchReady();
}
- // Klick auf Favorit (Home-Screen) -> direkt zu den Abfahrten
+ // Klick auf Favorit:
+ // - "Alle anzeigen" (Home) -> nur zur Abfahrten-View wechseln
+ // - Favorit auf Home -> Abfahrten-View + Station laden
+ // - Favoriten-Chip in der Abfahrten-View -> Station direkt wechseln
document.addEventListener('click', (e) => {
+ if (e.target.closest('#homeShowAllFavs')) {
+ switchView('departures');
+ return;
+ }
+
+ const chip = e.target.closest('.fav-chip');
+ if (chip) {
+ if (chip.dataset.id === currentStationId) return; // schon aktiv
+ selectStation(
+ chip.dataset.id, chip.dataset.name,
+ chip.dataset.lat ? parseFloat(chip.dataset.lat) : null,
+ chip.dataset.lon ? parseFloat(chip.dataset.lon) : null
+ );
+ return;
+ }
+
const favItem = e.target.closest('.home-favorite-item');
if (favItem) {
switchView('departures');
@@ -531,75 +535,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
// ==========================================
-// HOVER-EXTRAS (bereinigt)
-// Frühere Version zeigte FAKE-Daten (zufällige Gleisnummern,
-// erfundene "Genauigkeit ±10m", Alert-Attrappen für Favorit/Teilen/
-// Export/Kalender) und hatte einen Scope-Bug im Search-Hint,
-// der bei jedem Tastendruck einen ReferenceError warf.
+// HOVER-EXTRAS: KOMPLETT ENTFERNT (v37.1)
+// - Nav-Quick-Infos: standen als doppelte Beschriftung im Menü
+// - Search-Hints ("Tippe mindestens 2 Zeichen"): hatten kein CSS und
+// klebten als permanenter Rohtext unter den Suchfeldern
+// - data-tooltip-Attribute: hatten ebenfalls kein CSS -> tote Attribute
+// Die title-Attribute der Buttons liefern native Browser-Tooltips.
// ==========================================
-document.addEventListener('DOMContentLoaded', () => {
- if (window.matchMedia('(hover: hover)').matches) {
-
- // Navigation Items - Quick Info hinzufügen
- const navQuickInfos = [
- { selector: '[data-view="home"]', info: 'Startseite' },
- { selector: '[data-view="departures"]', info: 'Live-Abfahrten' },
- { selector: '[data-view="journey"]', info: 'Routenplaner' },
- { selector: '[data-view="livemap"]', info: 'Fahrzeuge live' },
- { selector: '[data-view="developer"]', info: 'Changelog & Infos' }
- ];
-
- navQuickInfos.forEach(item => {
- const el = document.querySelector(item.selector);
- if (el && !el.querySelector('.nav-quick-info')) {
- const info = document.createElement('div');
- info.className = 'nav-quick-info';
- info.textContent = item.info;
- el.appendChild(info);
- }
- });
-
- // Search Hints (Scope-Bug behoben: hint jetzt im richtigen Scope)
- document.querySelectorAll('.search-input').forEach(input => {
- let hint = input.parentElement.querySelector('.search-hint');
- if (!hint) {
- hint = document.createElement('div');
- hint.className = 'search-hint';
- hint.textContent = 'Tippe mindestens 2 Zeichen';
- input.parentElement.appendChild(hint);
- }
-
- input.addEventListener('input', () => {
- const len = input.value.length;
- if (len === 0) {
- hint.textContent = 'Tippe mindestens 2 Zeichen';
- } else if (len === 1) {
- hint.textContent = 'Noch 1 Zeichen...';
- } else {
- hint.textContent = 'Suche aktiv';
- }
- });
- });
-
- // Rich Tooltips (nur belegbare Aussagen)
- const tooltips = {
- '#refreshBtn': 'Lädt die neuesten Abfahrtszeiten. Auto-Refresh alle 30 Sekunden.',
- '#searchJourneyBtn': 'Sucht Routen zwischen zwei Stationen mit Echtzeit-Daten.',
- '#locationBtn': 'Findet die nächstgelegene Station per GPS.',
- '#swapBtn': 'Vertauscht Start- und Zielstation.'
- };
-
- Object.entries(tooltips).forEach(([selector, tooltip]) => {
- const el = document.querySelector(selector);
- if (el) {
- el.setAttribute('data-tooltip', tooltip);
- el.style.position = 'relative';
- }
- });
- }
-});
-
// Quick Actions an Abfahrten: ECHTE Funktionen statt Alert-Attrappen
// (Event-Delegation statt MutationObserver - robuster und günstiger)
document.addEventListener('click', async (e) => {
diff --git a/js/livemap.js b/js/livemap.js
index f35cd5f..47ba1fa 100644
--- a/js/livemap.js
+++ b/js/livemap.js
@@ -242,6 +242,62 @@
}
}
+ // ==========================================
+ // STRECKEN-ANZEIGE BEIM FAHRZEUG-KLICK
+ // Die Polylines liegen bereits dekodiert in liveMapSegments -
+ // die Route zu zeichnen kostet also NULL API-Requests.
+ // ==========================================
+ let activeRouteLine = null;
+ let focusedVehicleTripId = null; // Fokus-Modus: nur diese Fahrt zeigen
+
+ async function showVehicleRoute(tripId) {
+ hideVehicleRoute();
+ focusedVehicleTripId = tripId;
+
+ const seg = liveMapSegments.find(s => s.tripId === tripId);
+ const color = seg?.color || getBVGLineColor(seg?.lineName || '');
+
+ // 1) SOFORT das lokale Segment zeichnen (null Wartezeit)...
+ if (seg && seg.points && seg.points.length >= 2) {
+ activeRouteLine = L.polyline(seg.points, {
+ color, weight: 5, opacity: 0.75,
+ lineCap: 'round', interactive: false
+ }).addTo(liveMap);
+ }
+
+ // Fokus-Modus aktivieren: andere Fahrzeuge ausblenden
+ renderVehiclesFromSegments();
+
+ // 2) ...dann die KOMPLETTE Route nachladen (map/trips liefert nur
+ // das Segment im Ausschnitt; die ganze Strecke steckt in /trip).
+ // Gleicher Cache wie die Abfahrts-Details -> max. 1 Request.
+ try {
+ const route = await transitousTripGeometry(tripId);
+ // Nutzer könnte inzwischen woanders geklickt haben
+ if (route && focusedVehicleTripId === tripId) {
+ if (activeRouteLine) activeRouteLine.remove();
+ activeRouteLine = L.polyline(route.points, {
+ color: route.color || color, weight: 5, opacity: 0.75,
+ lineCap: 'round', interactive: false
+ }).addTo(liveMap);
+ }
+ } catch (e) {
+ // Volle Route nicht verfügbar -> Segment bleibt stehen
+ console.warn('Komplette Route nicht ladbar:', e.message);
+ }
+ }
+
+ function hideVehicleRoute() {
+ if (activeRouteLine) {
+ activeRouteLine.remove();
+ activeRouteLine = null;
+ }
+ if (focusedVehicleTripId) {
+ focusedVehicleTripId = null;
+ renderVehiclesFromSegments(); // alle Fahrzeuge wieder zeigen
+ }
+ }
+
// Aus den gespeicherten Segmenten die AKTUELLEN Positionen berechnen
// und rendern. Läuft jede Sekunde lokal - null API-Kosten.
function renderVehiclesFromSegments() {
@@ -250,6 +306,8 @@
const movements = [];
for (const seg of liveMapSegments) {
+ // Fokus-Modus: nur die angetippte Fahrt rendern
+ if (focusedVehicleTripId && seg.tripId !== focusedVehicleTripId) continue;
const pos = segmentPositionAt(seg, nowMs);
if (!pos) continue; // Fahrzeug (noch) nicht auf diesem Segment unterwegs
movements.push({
@@ -278,12 +336,18 @@
return activeVehicleTypes.has(vehicleType);
});
- // Update Counter mit Live-Indikator
+ // Update Counter mit Live-Indikator (bzw. Fokus-Hinweis)
const countEl = document.getElementById('vehicleCount');
if (countEl) {
- countEl.innerHTML =
- `
` +
- `
${filteredMovements.length} Fahrzeuge live`;
+ if (focusedVehicleTripId) {
+ const focusName = filteredMovements[0]?.line?.name || '';
+ countEl.innerHTML =
+ `🎯
${escapeHtml(focusName)} im Fokus – Popup schließen zum Beenden`;
+ } else {
+ countEl.innerHTML =
+ `
` +
+ `
${filteredMovements.length} Fahrzeuge live`;
+ }
}
const seenTripIds = new Set();
@@ -342,6 +406,9 @@
const marker = L.marker([lat, lon], { icon: icon });
marker.bindPopup(popupContent);
+ // Popup auf = Strecke dieser Fahrt einzeichnen (in Linienfarbe)
+ marker.on('popupopen', () => showVehicleRoute(tripId));
+ marker.on('popupclose', hideVehicleRoute);
marker.addTo(liveMap);
vehicleMarkerMap.set(tripId, { marker, lineName });
});
@@ -354,6 +421,12 @@
vehicleMarkerMap.delete(tripId);
}
}
+
+ // Route eines nicht mehr existierenden Fahrzeugs mit aufräumen
+ if (activeRouteLine && ![...vehicleMarkerMap.values()]
+ .some(e => e.marker.isPopupOpen && e.marker.isPopupOpen())) {
+ hideVehicleRoute();
+ }
}
// Filter Buttons mit Event Delegation
diff --git a/js/transitous.js b/js/transitous.js
index 5db48df..95ece79 100644
--- a/js/transitous.js
+++ b/js/transitous.js
@@ -135,10 +135,10 @@ async function transitousStopQuery({ id, name, lat, lon }) {
// ==========================================
// ABFAHRTEN (stoptimes -> HAFAS-departures-Form)
// ==========================================
-async function transitousDepartures(station) {
+async function transitousDepartures(station, n = 20) {
const stopQuery = await transitousStopQuery(station);
// 15s Cache: schützt vor Refresh-Spam
- const data = await transitousFetch(`/v6/stoptimes?${stopQuery}&n=20`, 15000);
+ const data = await transitousFetch(`/v6/stoptimes?${stopQuery}&n=${n}`, 15000);
const departures = (data.stopTimes || []).map(st => {
const p = st.place || {};
@@ -294,6 +294,31 @@ async function transitousTrip(tripId) {
return { trip: { stopovers } };
}
+// Kompletter Streckenverlauf einer Fahrt (für die Live-Map).
+// map/trips liefert nur das Segment im Kartenausschnitt - die GANZE
+// Route steckt in der legGeometry des /trip-Endpoints. Gleiche URL wie
+// transitousTrip -> geteilter 60s-Cache, Details+Route = 1 Request.
+async function transitousTripGeometry(tripId) {
+ const rawId = tripId.startsWith(TRANSITOUS_ID_PREFIX)
+ ? tripId.slice(TRANSITOUS_ID_PREFIX.length) : tripId;
+ const itinerary = await transitousFetch(
+ `/v6/trip?tripId=${encodeURIComponent(rawId)}`,
+ 60000
+ );
+
+ const leg = (itinerary.legs || []).find(l => l.mode !== 'WALK') || (itinerary.legs || [])[0];
+ if (!leg || !leg.legGeometry || !leg.legGeometry.points) return null;
+
+ const precision = leg.legGeometry.precision || 6;
+ const points = decodePolyline(leg.legGeometry.points, precision);
+ if (points.length < 2) return null;
+
+ return {
+ points,
+ color: motisColor(leg.routeColor)
+ };
+}
+
// ==========================================
// RADAR / LIVE-MAP (map/trips -> HAFAS-radar-Form)
//
@@ -303,8 +328,10 @@ async function transitousTrip(tripId) {
// Transitous-Web-Karte selbst auch).
// ==========================================
-// Google-Polyline dekodieren (Precision 5 laut MOTIS-Spec für map/trips)
-function decodePolyline5(encoded) {
+// Google-Polyline dekodieren. Die Precision variiert je Endpoint:
+// map/trips liefert 5, legGeometry (trip/plan) liefert precision als Feld mit.
+function decodePolyline(encoded, precision = 5) {
+ const factor = Math.pow(10, precision);
const points = [];
let index = 0, lat = 0, lon = 0;
while (index < encoded.length) {
@@ -318,11 +345,16 @@ function decodePolyline5(encoded) {
const delta = (result & 1) ? ~(result >> 1) : (result >> 1);
if (which === 0) lat += delta; else lon += delta;
}
- points.push([lat / 1e5, lon / 1e5]);
+ points.push([lat / factor, lon / factor]);
}
return points;
}
+// Kompatibilitäts-Alias (Tests + map/trips)
+function decodePolyline5(encoded) {
+ return decodePolyline(encoded, 5);
+}
+
async function transitousRadarSegments(bounds, zoom) {
const now = new Date();
const path = `/v6/map/trips?zoom=${Math.round(zoom)}` +
diff --git a/package.json b/package.json
index fe583b6..a7299af 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,13 @@
{
"name": "vbb-netz-status",
- "version": "36.0.0",
+ "version": "37.2.0",
"description": "Live-Nahverkehr für Berlin & Brandenburg - Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet",
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "node server.js"
+ "dev": "node server.js",
+ "test": "node tests/adapter.test.js && node tests/smoke.test.js",
+ "test:syntax": "node --check js/api.js && node --check js/transitous.js && node --check js/app.js && node --check js/livemap.js && node --check js/changelog.js && node --check js/extras.js && node --check service-worker.js"
},
"keywords": [
"vbb",
@@ -33,9 +35,11 @@
"dependencies": {
"mime-types": "^2.1.35"
},
- "devDependencies": {},
+ "devDependencies": {
+ "jsdom": "^24.0.0"
+ },
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/service-worker.js b/service-worker.js
index 7d9ea98..731f15e 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'vbb-status-v36';
+const CACHE_NAME = 'vbb-status-v37.2.0';
// App-Shell: nur Dateien, die wirklich existieren!
// (Der alte SW listete eine nicht existierende Font-Datei – dadurch
diff --git a/styles.css b/styles.css
index ee8e273..244f86a 100644
--- a/styles.css
+++ b/styles.css
@@ -1017,11 +1017,6 @@
padding: 20px;
}
- .detail-modal {
- left: 50%;
- transform: translateX(-50%);
- max-width: 600px;
- }
}
/* Pull-to-refresh styles */
@@ -1352,17 +1347,6 @@
padding: 30px 40px;
}
- /* Abfahrten in 2 Spalten */
- #departuresContainer {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 20px;
- }
-
- .station-info {
- grid-column: 1 / -1;
- }
-
/* Journey auch größer */
.journey-result {
padding: 30px;
@@ -1376,17 +1360,9 @@
/* Extra Large Desktop */
@media (min-width: 1600px) {
.container {
- max-width: 1400px;
+ max-width: 820px; /* Lesebreite statt Riesenfläche */
}
- /* 3 Spalten für Abfahrten */
- #departuresContainer {
- grid-template-columns: repeat(3, 1fr);
- }
-
- .station-info {
- grid-column: 1 / -1;
- }
}
/* Mobile Optimierungen */
@@ -2252,10 +2228,6 @@
========================================== */
.departure-filters, .journey-transport-filters {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
padding: 12px;
background-color: #0a0a0a;
border-radius: 8px;
@@ -2266,7 +2238,14 @@
color: #FFED00;
font-size: 13px;
font-weight: bold;
- margin-right: 5px;
+ margin-bottom: 8px;
+ }
+
+ /* Einheitliche Chip-Reihe für ALLE Filtergruppen */
+ .filter-chips {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
}
.filter-btn, .transport-filter-btn {
@@ -2282,10 +2261,8 @@
flex-shrink: 0;
}
- .transport-filter-btn {
- font-size: 20px;
- padding: 8px 12px;
- }
+ /* (Sondergröße entfernt - Journey-Chips waren doppelt so groß
+ wie alle anderen Filter und brachen unruhig um) */
.filter-btn.active, .transport-filter-btn.active {
background-color: #FFED00;
@@ -3953,11 +3930,140 @@ select:focus-visible {
min-width: 76px;
}
- .detail-modal {
- max-width: 720px;
- }
-
.livemap-container {
height: calc(100vh - 100px);
}
}
+
+/* v37: "Mehr Abfahrten laden"-Button */
+.load-more-btn {
+ margin-top: 14px;
+ background: rgba(255, 237, 0, 0.08);
+ border: 1px dashed rgba(255, 237, 0, 0.4);
+ color: #FFED00;
+ font-size: 14px;
+}
+
+.load-more-btn:hover,
+.load-more-btn:focus-visible {
+ background: rgba(255, 237, 0, 0.15);
+ border-style: solid;
+}
+
+/* ==========================================
+ v37.1: Detail-Modal auf Desktop
+ Overlay bleibt Vollbild (nichts scheint durch),
+ Inhalt zentriert auf Lesebreite.
+ ========================================== */
+@media (min-width: 768px) {
+ .detail-content {
+ max-width: 680px;
+ margin: 0 auto;
+ padding-left: 20px;
+ padding-right: 20px;
+ }
+
+ .detail-header {
+ padding-left: calc(50% - 340px);
+ padding-right: calc(50% - 340px);
+ }
+}
+
+/* v37.1.1: Entwickler-Kachel im Quick-Info-Raster
+ (Button erbt die Kachel-Optik von .home-info-card, hier nur
+ der Button-Reset + Klick-Feedback) */
+.home-info-card-btn {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ text-align: center;
+ cursor: pointer;
+ width: 100%;
+ transition: background 0.2s ease, border-color 0.2s ease;
+}
+
+.home-info-card-btn:hover,
+.home-info-card-btn:focus-visible {
+ background: rgba(255, 237, 0, 0.08);
+ border-color: rgba(255, 237, 0, 0.5);
+ outline: none;
+}
+
+.home-info-card-btn .home-info-text p span {
+ color: #FFED00;
+ font-weight: bold;
+}
+
+/* ==========================================
+ v37.1.1: Filter als wischbare Chip-Reihe auf Mobile
+ Gilt einheitlich für Abfahrten-, Routenplaner- und Live-Map-Filter.
+ ========================================== */
+@media (max-width: 767px) {
+ .filter-chips,
+ .livemap-filters {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ justify-content: flex-start;
+ padding-bottom: 4px; /* Platz, falls Scrollbar auftaucht */
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none; /* Firefox */
+ }
+
+ .filter-chips::-webkit-scrollbar,
+ .livemap-filters::-webkit-scrollbar {
+ display: none; /* Chrome/Safari */
+ }
+
+ .filter-btn,
+ .transport-filter-btn,
+ .fav-chip {
+ white-space: nowrap; /* Chips brechen nie intern um */
+ min-height: 40px; /* Touch-Ziel */
+ }
+}
+
+/* ==========================================
+ v37.2: Favoriten-Chips in der Abfahrten-View
+ (nutzt .filter-chips -> auf Mobile wischbare Reihe wie die Filter)
+ ========================================== */
+.departure-favorites {
+ padding: 12px;
+ background-color: #0a0a0a;
+ border: 1px solid rgba(255, 237, 0, 0.2);
+ border-radius: 8px;
+ margin-bottom: 12px;
+}
+
+.fav-chip {
+ background-color: #1a1a1a;
+ border: 2px solid #333;
+ color: #fff;
+ padding: 8px 14px;
+ border-radius: 999px; /* Pillenform: unterscheidet Stationen von Filtern */
+ font-family: var(--font-stack);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+ flex-shrink: 0;
+ white-space: nowrap;
+ min-height: 40px;
+}
+
+.fav-chip:hover,
+.fav-chip:focus-visible {
+ border-color: #FFED00;
+ outline: none;
+}
+
+.fav-chip.active {
+ background-color: #FFED00;
+ color: #000;
+ border-color: #FFED00;
+ font-weight: bold;
+}
+
+/* "Alle anzeigen" auf der Startseite dezent abheben */
+.home-favorites-more {
+ border-style: dashed;
+ opacity: 0.85;
+}
diff --git a/tests/adapter.test.js b/tests/adapter.test.js
new file mode 100644
index 0000000..ec55e23
--- /dev/null
+++ b/tests/adapter.test.js
@@ -0,0 +1,176 @@
+// Transitous-Adapter: Normalisierung MOTIS -> HAFAS-Form testen
+const fs = require('fs');
+const path = require('path');
+const api = fs.readFileSync(path.join(__dirname, '..', 'js', 'api.js'), 'utf8');
+const adapter = fs.readFileSync(path.join(__dirname, '..', 'js', 'transitous.js'), 'utf8');
+
+// Minimale Umgebung
+global.document = { getElementById: () => null, createElement: () => ({ style: {}, setAttribute(){}, prepend(){} }), body: { prepend(){} } };
+global.localStorage = { _d: {}, getItem(k){ return this._d[k] ?? null; }, setItem(k,v){ this._d[k]=v; }, };
+global.AbortController = class { constructor(){ this.signal = {}; } abort(){} };
+
+// MOTIS-Fixtures (Struktur laut OpenAPI v2.10.2)
+const fixtures = {
+ geocode: [{ type: 'STOP', name: 'S+U Alexanderplatz', id: 'de-DELFI_de:11000:900100003', lat: 52.5215, lon: 13.4112, tokens: [], areas: [], score: 1 }],
+ stoptimes: { stopTimes: [{
+ place: { name: 'S+U Alexanderplatz', stopId: 'de-DELFI_de:11000:900100003', lat: 52.52, lon: 13.41,
+ departure: '2026-07-06T10:05:00+02:00', scheduledDeparture: '2026-07-06T10:03:00+02:00', track: '4' },
+ mode: 'SUBWAY', realTime: true, headsign: 'U Ruhleben', routeShortName: 'U2', tripId: 'trip123',
+ agencyName: 'BVG', cancelled: false, tripCancelled: false
+ }], place: {}, previousPageCursor: '', nextPageCursor: '' },
+ plan: { itineraries: [{
+ duration: 1200, startTime: '2026-07-06T10:00:00+02:00', endTime: '2026-07-06T10:20:00+02:00', transfers: 0, id: 'it1',
+ legs: [
+ { mode: 'WALK', from: { name: 'Start', lat: 0, lon: 0 }, to: { name: 'S Südkreuz', lat: 0, lon: 0 },
+ startTime: '2026-07-06T10:00:00+02:00', endTime: '2026-07-06T10:04:00+02:00',
+ scheduledStartTime: '2026-07-06T10:00:00+02:00', scheduledEndTime: '2026-07-06T10:04:00+02:00',
+ realTime: false, scheduled: true, duration: 240, legGeometry: {} },
+ { mode: 'SUBURBAN', from: { name: 'S Südkreuz', track: '2', lat: 0, lon: 0 }, to: { name: 'S+U Alexanderplatz', lat: 0, lon: 0 },
+ startTime: '2026-07-06T10:06:00+02:00', endTime: '2026-07-06T10:20:00+02:00',
+ scheduledStartTime: '2026-07-06T10:05:00+02:00', scheduledEndTime: '2026-07-06T10:19:00+02:00',
+ realTime: true, scheduled: false, duration: 840, routeShortName: 'S2', headsign: 'S Bernau', legGeometry: {} }
+ ]
+ }] }
+};
+
+function encodePolyline5(points) {
+ let out = '', prevLat = 0, prevLon = 0;
+ const enc = (v) => {
+ let x = v < 0 ? ~(v << 1) : (v << 1), s = '';
+ while (x >= 0x20) { s += String.fromCharCode((0x20 | (x & 0x1f)) + 63); x >>= 5; }
+ return s + String.fromCharCode(x + 63);
+ };
+ for (const [lat, lon] of points) {
+ const la = Math.round(lat * 1e5), lo = Math.round(lon * 1e5);
+ out += enc(la - prevLat) + enc(lo - prevLon);
+ prevLat = la; prevLon = lo;
+ }
+ return out;
+}
+
+fixtures.mapStops = [
+ { name: 'U Amrumer Str.', stopId: 'de-DELFI_de:11000:900009202', lat: 52.5423, lon: 13.3495 },
+ { name: 'S Westhafen', stopId: 'de-DELFI_de:11000:900001201', lat: 52.5360, lon: 13.3430 }
+];
+fixtures.trip = { duration: 600, startTime: 'x', endTime: 'x', transfers: 0, id: 't', legs: [{
+ mode: 'SUBWAY', realTime: true, scheduled: true, duration: 600, legGeometry: {},
+ startTime: 'x', endTime: 'x', scheduledStartTime: 'x', scheduledEndTime: 'x',
+ from: { name: 'U Ruhleben', stopId: 'A', scheduledDeparture: '2026-07-06T10:00:00+02:00', departure: '2026-07-06T10:01:00+02:00' },
+ to: { name: 'S+U Pankow', stopId: 'B', scheduledArrival: '2026-07-06T10:40:00+02:00', arrival: '2026-07-06T10:41:00+02:00' },
+ intermediateStops: [
+ { name: 'U Theodor-Heuss-Platz', stopId: 'C', scheduledDeparture: '2026-07-06T10:05:00+02:00', departure: '2026-07-06T10:06:30+02:00' }
+ ]
+}] };
+// Segment: 2 Punkte, Fahrt läuft JETZT (Start vor 30s, Ende in 30s)
+const segNow = Date.now();
+fixtures.mapTrips = [{
+ trips: [{ tripId: 'RT1', routeShortName: 'M10', displayName: 'M10' }],
+ mode: 'TRAM', distance: 1000,
+ from: { name: 'A', lat: 52.5, lon: 13.3 }, to: { name: 'B Endstation', lat: 52.6, lon: 13.4 },
+ departure: new Date(segNow - 30000).toISOString(),
+ arrival: new Date(segNow + 30000).toISOString(),
+ scheduledDeparture: new Date(segNow - 90000).toISOString(),
+ scheduledArrival: new Date(segNow - 30000).toISOString(),
+ realTime: true,
+ polyline: encodePolyline5([[52.5, 13.3], [52.6, 13.4]])
+}];
+
+const requestedUrls = [];
+global.fetch = async (url) => {
+ requestedUrls.push(url);
+ const body = url.includes('/geocode') ? fixtures.geocode
+ : url.includes('/stoptimes') ? fixtures.stoptimes
+ : url.includes('/plan') ? fixtures.plan
+ : url.includes('/map/stops') ? fixtures.mapStops
+ : url.includes('/map/trips') ? fixtures.mapTrips
+ : url.includes('/trip') ? fixtures.trip : {};
+ return { ok: true, json: async () => body };
+};
+
+// api.js-Ausschnitte + Adapter in EINEM Scope evaluieren
+const combined =
+ api.slice(api.indexOf('const API_SOFT_LIMIT'), api.indexOf('/**'))
+ + api.slice(api.indexOf('async function apiFetch'), api.indexOf('// ==========================================\n // GLOBALES'))
+ + api.slice(api.indexOf('function showApiOutageBanner'), api.indexOf('// HTML-Escaping'))
+ + api.slice(api.indexOf('function storageGet'), api.indexOf('// --- Favoriten ---'))
+ + adapter
+ + ';global.__a = { transitousLocations, transitousDepartures, transitousJourneys, motisModeToProduct, productsToMotisModes, transitousNearby, transitousTrip, transitousRadarSegments, segmentPositionAt, decodePolyline5, apiCache };';
+eval(combined);
+const A = global.__a;
+
+(async () => {
+ let fails = 0;
+ const t = (name, ok) => { console.log((ok ? '✅' : '❌'), name); if (!ok) fails++; };
+
+ // 1) Locations-Normalisierung
+ const locs = await A.transitousLocations('alex');
+ t('geocode -> hafas locations', locs[0].type === 'stop'
+ && locs[0].id === 'transitous:de-DELFI_de:11000:900100003'
+ && locs[0].name === 'S+U Alexanderplatz'
+ && locs[0].location.latitude === 52.5215);
+
+ // 2) Departures über Transitous-ID
+ const deps = await A.transitousDepartures({ id: 'transitous:de-DELFI_de:11000:900100003', name: 'Alex' });
+ const d = deps.departures[0];
+ t('stoptimes -> hafas departure (Linie/Richtung)', d.line.name === 'U2' && d.line.product === 'subway' && d.direction === 'U Ruhleben');
+ t('Verspätung korrekt (120s)', d.delay === 120);
+ t('when/plannedWhen/Gleis', d.when.includes('10:05') && d.plannedWhen.includes('10:03') && d.platform === '4');
+
+ // 3) Departures über Koordinaten (kein ID-Mapping nötig)
+ requestedUrls.length = 0;
+ await A.transitousDepartures({ id: '900100003', name: 'Alex', lat: 52.52, lon: 13.41 });
+ t('HAFAS-ID + Koordinaten -> center-Query statt Geocode', requestedUrls.length === 1 && requestedUrls[0].includes('center=52.52,13.41'));
+
+ // 4) Departures nur mit Name (alter Favorit) -> Geocode + Cache
+ A.apiCache.clear();
+ requestedUrls.length = 0;
+ await A.transitousDepartures({ id: '900xyz', name: 'S Südkreuz' });
+ t('Nur Name -> geocode + stoptimes (2 Requests)', requestedUrls.length === 2 && requestedUrls[0].includes('/geocode'));
+ A.apiCache.clear();
+ requestedUrls.length = 0;
+ await A.transitousDepartures({ id: '900xyz', name: 'S Südkreuz' });
+ t('ID-Mapping gecacht (nur noch 1 Request)', requestedUrls.length === 1);
+
+ // 5) Journeys-Normalisierung inkl. Filter und Zeit
+ requestedUrls.length = 0;
+ const j = await A.transitousJourneys(
+ { id: 'x', name: 'Start', lat: 52.47, lon: 13.36 },
+ { id: 'y', name: 'Ziel', lat: 52.52, lon: 13.41 },
+ { timeISO: '2026-07-06T08:00:00.000Z', arriveBy: true, products: new Set(['suburban', 'subway']) }
+ );
+ const leg = j.journeys[0].legs[1];
+ t('plan -> hafas journey (Leg-Felder)', leg.line.name === 'S2' && leg.line.product === 'suburban'
+ && leg.origin.name === 'S Südkreuz' && leg.departurePlatform === '2');
+ t('Walk-Leg markiert', j.journeys[0].legs[0].walking === true && j.journeys[0].legs[0].line === undefined);
+ const planUrl = requestedUrls.find(u => u.includes('/plan'));
+ t('Koordinaten als fromPlace', planUrl.includes('fromPlace=52.47%2C13.36'));
+ t('arriveBy + Zeit in URL', planUrl.includes('arriveBy=true') && planUrl.includes('time='));
+ t('transitModes mit Fähre', planUrl.includes('SUBURBAN') && planUrl.includes('SUBWAY') && planUrl.includes('FERRY') && !planUrl.includes('TRAM'));
+
+ // 6) Nearby: map/stops -> sortiert nach Distanz
+ const near = await A.transitousNearby(52.5423, 13.3495);
+ t('Nearby: nächste Station zuerst + Distanz', near[0].name === 'U Amrumer Str.' && near[0].distance < near[1].distance);
+
+ // 7) Trip-Details: stopovers in hafas-Form
+ const tripRes = await A.transitousTrip('transitous:RT1');
+ const so = tripRes.trip.stopovers;
+ t('Trip: from + intermediate + to = 3 Stopovers', so.length === 3 && so[1].stop.name === 'U Theodor-Heuss-Platz');
+ t('Trip: Verspätung am Zwischenhalt (90s)', so[1].departureDelay === 90);
+
+ // 8) Polyline-Decoder (Roundtrip-Referenzwerte)
+ const pts = A.decodePolyline5('_p~iF~ps|U_ulLnnqC_mqNvxq`@');
+ t('Polyline-Decoder (Google-Referenzbeispiel)',
+ Math.abs(pts[0][0] - 38.5) < 1e-9 && Math.abs(pts[0][1] + 120.2) < 1e-9 && pts.length === 3);
+
+ // 9) Radar-Segmente + Interpolation: Fahrt zur Hälfte -> Mittelpunkt
+ const segs = await A.transitousRadarSegments({ north: 52.7, south: 52.4, west: 13.2, east: 13.6 }, 12);
+ t('Radar: Segment normalisiert', segs.length === 1 && segs[0].lineName === 'M10' && segs[0].product === 'tram');
+ const pos = A.segmentPositionAt(segs[0], Date.now());
+ t('Interpolation: Position ~Mittelpunkt der Strecke',
+ pos && Math.abs(pos.latitude - 52.55) < 0.01 && Math.abs(pos.longitude - 13.35) < 0.01);
+ t('Interpolation: außerhalb des Zeitfensters -> null',
+ A.segmentPositionAt(segs[0], Date.now() + 120000) === null);
+
+ console.log(fails === 0 ? '\n🎉 TRANSITOUS-ADAPTER-TESTS BESTANDEN' : `\n❌ ${fails} fehlgeschlagen`);
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
new file mode 100644
index 0000000..8de63df
--- /dev/null
+++ b/tests/smoke.test.js
@@ -0,0 +1,265 @@
+const fs = require('fs');
+const path = require('path');
+const { JSDOM } = require('jsdom');
+
+const dir = path.join(__dirname, '..');
+const html = fs.readFileSync(path.join(dir, 'index.html'), 'utf8');
+const moduleFiles = ['js/api.js', 'js/transitous.js', 'js/app.js', 'js/livemap.js', 'js/changelog.js', 'js/extras.js'];
+
+const cleanHtml = html
+ .replace(/