Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-07-07 10:12:43 +02:00
1 parent fa38de2a0a
commit 693e208cfd
12 files changed
+943 -185

No files matched your search

+50
View File
@@ -5,6 +5,56 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [37.2.0] - 2026-07-07
### Favoriten-Chips in der Abfahrten-View
#### Changed
- **Favoriten wohnen jetzt in der Abfahrten-View**: Über dem Suchfeld sitzt eine wischbare Chip-Reihe (Pillenform) mit ALLEN Favoriten - die aktive Station ist gelb markiert, ein Tap wechselt die Station ohne Umweg über die Startseite. Auf Mobile gleiche Wisch-Mechanik wie die Filter-Chips.
- **Startseite aufgeräumt**: zeigt nur noch kompakt die Top 3 Favoriten plus "Alle N Stationen anzeigen" (springt in die Abfahrten-View), statt bis zu 10 Einträge zu stapeln.
- Stationswechsel frischt die Aktiv-Markierung der Chips automatisch auf.
## [37.1.1] - 2026-07-07
### Komplette Route + Fokus-Modus, Entwickler-Tab, eine Versionsnummer
#### Added - Live-Map
- **Komplette Route beim Fahrzeug-Tap**: map/trips liefert nur das Segment im Kartenausschnitt - beim Antippen wird jetzt sofort das lokale Segment gezeichnet und im Hintergrund die GANZE Strecke über /v6/trip nachgeladen (gleicher 60s-Cache wie die Abfahrts-Details: Details + Route = maximal 1 Request).
- **Fokus-Modus**: Solange das Popup offen ist, werden alle anderen Fahrzeuge ausgeblendet - nur die angetippte Fahrt mit ihrer Route bleibt sichtbar. Der Zähler zeigt "🎯 [Linie] im Fokus". Popup schließen stellt alles wieder her.
- Polyline-Decoder generalisiert (Precision 5/6/7 je Endpoint, legGeometry liefert precision als Feld).
#### Fixed - Filter auf Mobile
- **Alle Filtergruppen vereinheitlicht** (Abfahrten, Routenplaner, Live-Map): Label auf eigener Zeile, Buttons als EINE horizontal wischbare Chip-Reihe statt zerklüftetem Umbruch. Die Routenplaner-Buttons hatten zudem eine 20px-Sondergröße (doppelt so groß wie alle anderen Filter) - jetzt Einheitsgröße mit 40px-Touch-Zielen.
#### Changed - Entwickler-Bereich & Version
- **Eine Versionsnummer für alles**: APP_VERSION (js/api.js) ist die einzige Quelle; Startseite, Entwickler-Tab und Cache-Anzeige werden daraus befüllt. Ein neuer Smoke-Test erzwingt, dass package.json, APP_VERSION und SW-Cache-Name übereinstimmen - Versions-Drift wie bisher (v30/v33 auf der Website) fällt ab jetzt in der CI auf.
- **Entwickler-Tab aktualisiert**: veraltete Angaben korrigiert (15s-Radar -> 30s+Animation, HAFAS-Endpoints -> MOTIS, BVG-Farbtabelle -> GTFS-Farben, "Push geplant" -> Favoriten/Offline), Statistik-Box durch Architektur-Fakten ersetzt.
- **Entwickler-Kachel auf der Startseite**: vierte Kachel im Feature-Raster (neben Live-Map) mit Versionsnummer, öffnet den Entwickler-Bereich. Füllt nebenbei die leere Rasterlücke im 2-Spalten-Layout.
## [37.1.0] - 2026-07-07
### Desktop-Aufräumaktion (nach Screenshots)
#### Fixed
- **Doppelte Beschriftung im Menü**: Die per JS injizierten "Nav-Quick-Infos" standen ohne CSS als zweites Label neben jedem Menüpunkt ("Start | Startseite"). Komplett entfernt - ebenso die Search-Hints ("Tippe mindestens 2 Zeichen"), die als permanenter Rohtext unter den Suchfeldern klebten, und tote data-tooltip-Attribute ohne CSS.
- **Abfahrten auf Desktop unsymmetrisch**: Das 2-Spalten- (ab 768px) bzw. 3-Spalten-Grid (ab 1600px) erzeugte ungleich hohe, unruhige Karten. Jetzt überall eine ruhige einspaltige Liste; die Breite regelt der zentrierte Container. Die 1600px-Stufe nutzt Lesebreite (820px) statt 1400px Riesenfläche.
- **Detail-Modal auf Desktop kaputt**: Eine alte Regel machte das Vollbild-Overlay selbst 600px schmal und links verankert - die Seite schaute rechts durch, mit eigener Scrollbar in der Mitte. Jetzt: Overlay bleibt Vollbild (nichts scheint durch), nur der Inhalt ist auf Lesebreite zentriert.
#### Hinweis zur OS-Frage
Layout-Anpassung läuft bewusst über Viewport-Breite und hover/pointer-Media-Queries statt User-Agent/OS-Erkennung: Touch-Laptops und große Tablets brechen jede OS-Annahme, und Browser reduzieren den User-Agent zunehmend. Die Media-Queries waren richtig - nur an drei Stellen kaputt.
## [37.0.0] - 2026-07-07
### Tests im Repo, funktionierender Abfahrten-Filter, Strecken-Anzeige, Mehr-laden
#### Added
- **Tests + CI im Repo**: 19 Adapter-Tests und der komplette Smoke-Test liegen jetzt unter tests/ (`npm test`), plus GitHub Action (.github/workflows/test.yml), die bei jedem Push Syntax-Checks und beide Test-Suiten ausführt.
- **Strecken-Anzeige auf der Live-Map**: Fahrzeug antippen -> die komplette Route der Fahrt wird in der Linienfarbe eingezeichnet (Popup schließen -> Linie verschwindet). Die Polylines liegen bereits im Speicher - kostet null API-Requests.
- **"Mehr Abfahrten laden"**: Button unter der Abfahrtsliste erhöht die Anzahl schrittweise (20 -> 40 -> 60). Bewusst über den n-Parameter statt Cursor gelöst: die erweiterte Liste bleibt so auch beim 30s-Auto-Refresh erhalten. Stationswechsel setzt auf 20 zurück.
#### Fixed
- **Abfahrten-Filter repariert und entpatcht**: Der Filter (U-Bahn/S-Bahn/Tram/Bus/Regional über den Abfahrten) hing an fragilen Monkey-Patches, die loadDepartures/displayDepartures zur Laufzeit überschrieben - und hatte einen echten Bug: Bei aktivem Filter hat der Auto-Refresh alle 30 Sekunden die UNGEFILTERTE Liste gerendert (der tripId-Vergleich lief gegen die gefilterte DOM-Sicht und schlug immer fehl). Jetzt: displayDepartures merkt sich die volle Liste und filtert intern, der Auto-Refresh vergleicht gegen die gefilterte Sicht, die Monkey-Patches sind entfernt.
## [36.0.0] - 2026-07-06 ## [36.0.0] - 2026-07-06
### Schnellere Karte, offizielle Linienfarben, Desktop-Layout ### Schnellere Karte, offizielle Linienfarben, Desktop-Layout
+32 -15
View File
@@ -102,7 +102,7 @@
<div class="home-hero-title"> <div class="home-hero-title">
<h1>VBB NETZ STATUS</h1> <h1>VBB NETZ STATUS</h1>
<p>Live-Nahverkehr für Berlin & Brandenburg</p> <p>Live-Nahverkehr für Berlin & Brandenburg</p>
<div class="home-version">v36.0</div> <div class="home-version" id="homeVersion"></div>
</div> </div>
</div> </div>
@@ -132,6 +132,14 @@
<p>Fahrzeuge in Echtzeit verfolgen</p> <p>Fahrzeuge in Echtzeit verfolgen</p>
</div> </div>
</div> </div>
<button class="home-info-card home-info-card-btn" id="homeDevCard"
aria-label="Entwickler-Bereich mit Changelog öffnen">
<div class="home-info-icon">👨‍💻</div>
<div class="home-info-text">
<strong>Für Entwickler</strong>
<p>Changelog &amp; Infos – <span id="homeDevVersion"></span></p>
</div>
</button>
</div> </div>
@@ -151,6 +159,10 @@
<!-- View: Abfahrten --> <!-- View: Abfahrten -->
<div class="view-container" id="view-departures"> <div class="view-container" id="view-departures">
<div class="container"> <div class="container">
<!-- Favoriten-Chips: 1-Tap-Wechsel zwischen deinen Stationen -->
<div class="departure-favorites" id="departureFavorites" style="display: none;"
role="group" aria-label="Favoriten-Stationen"></div>
<div class="controls"> <div class="controls">
<div class="search-row"> <div class="search-row">
<div class="search-container"> <div class="search-container">
@@ -176,6 +188,7 @@
<!-- Filter für Linientypen --> <!-- Filter für Linientypen -->
<div class="departure-filters" id="departureFilters" style="display: none;"> <div class="departure-filters" id="departureFilters" style="display: none;">
<div class="filter-label">Filter:</div> <div class="filter-label">Filter:</div>
<div class="filter-chips">
<button class="filter-btn active" data-filter="all">Alle</button> <button class="filter-btn active" data-filter="all">Alle</button>
<button class="filter-btn" data-filter="subway">🚇 U-Bahn</button> <button class="filter-btn" data-filter="subway">🚇 U-Bahn</button>
<button class="filter-btn" data-filter="suburban">🚆 S-Bahn</button> <button class="filter-btn" data-filter="suburban">🚆 S-Bahn</button>
@@ -184,6 +197,7 @@
<button class="filter-btn" data-filter="regional">🚄 Regional</button> <button class="filter-btn" data-filter="regional">🚄 Regional</button>
</div> </div>
</div> </div>
</div>
<div id="departuresContainer" class="departures-container"></div> <div id="departuresContainer" class="departures-container"></div>
</div> </div>
@@ -264,6 +278,7 @@
<!-- Verkehrsmittel-Filter --> <!-- Verkehrsmittel-Filter -->
<div class="journey-transport-filters"> <div class="journey-transport-filters">
<div class="filter-label">Verkehrsmittel:</div> <div class="filter-label">Verkehrsmittel:</div>
<div class="filter-chips">
<button class="transport-filter-btn active" data-transport="subway">🚇 U-Bahn</button> <button class="transport-filter-btn active" data-transport="subway">🚇 U-Bahn</button>
<button class="transport-filter-btn active" data-transport="suburban">🚆 S-Bahn</button> <button class="transport-filter-btn active" data-transport="suburban">🚆 S-Bahn</button>
<button class="transport-filter-btn active" data-transport="tram">🚊 Tram</button> <button class="transport-filter-btn active" data-transport="tram">🚊 Tram</button>
@@ -272,6 +287,7 @@
<button class="transport-filter-btn active" data-transport="express">🚅 Express</button> <button class="transport-filter-btn active" data-transport="express">🚅 Express</button>
</div> </div>
</div> </div>
</div>
<div id="journeyContainer" class="journey-container"></div> <div id="journeyContainer" class="journey-container"></div>
</div> </div>
@@ -324,8 +340,8 @@
<div class="dev-info-box"> <div class="dev-info-box">
<h3>🚀 Version Info</h3> <h3>🚀 Version Info</h3>
<table class="dev-info-table"> <table class="dev-info-table">
<tr><td>Version:</td><td><strong>30.0</strong></td></tr> <tr><td>Version:</td><td><strong id="devVersion"></strong></td></tr>
<tr><td>Release:</td><td>10.02.2026</td></tr> <tr><td>Release:</td><td id="devRelease"></td></tr>
<tr><td>Lizenz:</td><td>MIT</td></tr> <tr><td>Lizenz:</td><td>MIT</td></tr>
<tr><td>Status:</td><td><span class="status-badge-dev">✅ Stabil</span></td></tr> <tr><td>Status:</td><td><span class="status-badge-dev">✅ Stabil</span></td></tr>
</table> </table>
@@ -353,12 +369,12 @@
</div> </div>
<div class="dev-info-box"> <div class="dev-info-box">
<h3>📊 Statistiken</h3> <h3>📊 Architektur</h3>
<table class="dev-info-table"> <table class="dev-info-table">
<tr><td>HTML:</td><td>407 Zeilen</td></tr> <tr><td>Module:</td><td>6 (api, transitous, app, livemap, changelog, extras)</td></tr>
<tr><td>CSS:</td><td>2689 Zeilen</td></tr> <tr><td>Datenquelle:</td><td>Transitous / MOTIS 2</td></tr>
<tr><td>JavaScript:</td><td>2161 Zeilen</td></tr> <tr><td>Tests:</td><td>Adapter- + Smoke-Suite (GitHub Actions)</td></tr>
<tr><td>Gesamt:</td><td>~5.300 Zeilen Code</td></tr> <tr><td>Code:</td><td>~8.500 Zeilen (HTML, CSS, JS)</td></tr>
</table> </table>
</div> </div>
@@ -385,7 +401,7 @@
<li>Linien-Filter</li> <li>Linien-Filter</li>
<li>Detail-Ansicht mit Zwischenhalten</li> <li>Detail-Ansicht mit Zwischenhalten</li>
</ul> </ul>
<p class="feature-api"><strong>API:</strong> <code>GET /stops/:id/departures</code></p> <p class="feature-api"><strong>API:</strong> <code>GET /v6/stoptimes</code></p>
</div> </div>
</details> </details>
@@ -401,7 +417,7 @@
<li>Verkehrsmittel-Filter</li> <li>Verkehrsmittel-Filter</li>
<li>Detail-Ansicht mit allen Haltestellen</li> <li>Detail-Ansicht mit allen Haltestellen</li>
</ul> </ul>
<p class="feature-api"><strong>API:</strong> <code>GET /journeys</code></p> <p class="feature-api"><strong>API:</strong> <code>GET /v6/plan</code></p>
</div> </div>
</details> </details>
@@ -413,11 +429,12 @@
<li>Leaflet.js Kartenintegration</li> <li>Leaflet.js Kartenintegration</li>
<li>Dynamische Bounding Box</li> <li>Dynamische Bounding Box</li>
<li>Fahrzeug-Filter (U-Bahn, S-Bahn, Bus, Tram)</li> <li>Fahrzeug-Filter (U-Bahn, S-Bahn, Bus, Tram)</li>
<li>BVG-Linienfarben</li> <li>Offizielle Linienfarben aus dem GTFS-Feed</li>
<li>Auto-Update alle 15s</li> <li>Strecken-Anzeige + Fokus-Modus per Fahrzeug-Tap</li>
<li>API-Poll alle 30s + lokale 1s-Animation</li>
<li>Zoom & Pan optimiert</li> <li>Zoom & Pan optimiert</li>
</ul> </ul>
<p class="feature-api"><strong>API:</strong> <code>GET /radar</code></p> <p class="feature-api"><strong>API:</strong> <code>GET /v6/map/trips</code></p>
</div> </div>
</details> </details>
@@ -429,10 +446,10 @@
<li>Installierbar (Add to Home Screen)</li> <li>Installierbar (Add to Home Screen)</li>
<li>Service Worker mit Caching</li> <li>Service Worker mit Caching</li>
<li>Offline-Modus</li> <li>Offline-Modus</li>
<li>Push-Benachrichtigungen (geplant)</li> <li>Favoriten &amp; Offline-Abfahrten (localStorage)</li>
<li>App-Manifest</li> <li>App-Manifest</li>
</ul> </ul>
<p class="feature-api"><strong>Cache:</strong> <code>vbb-status-v36</code></p> <p class="feature-api"><strong>Cache:</strong> <code id="devCacheName"></code></p>
</div> </div>
</details> </details>
+48 -3
View File
@@ -4,6 +4,12 @@
// VBB NETZ STATUS - Haupt-Script // VBB NETZ STATUS - Haupt-Script
// ========================================== // ==========================================
// ZENTRALE VERSIONSNUMMER - einzige Stelle, die bei Releases
// angepasst wird. Alle Anzeigen (Startseite, Entwickler-Tab)
// lesen von hier; package.json + SW-Cache-Name manuell mitziehen.
const APP_VERSION = '37.2.0';
const APP_RELEASE_DATE = '07.07.2026';
// Seit v35: Transitous (api.transitous.org) ist die einzige // Seit v35: Transitous (api.transitous.org) ist die einzige
// Datenquelle. transport.rest (VBB/BVG) wurde komplett entfernt. // Datenquelle. transport.rest (VBB/BVG) wurde komplett entfernt.
// Die konkreten Endpoints kapselt js/transitous.js. // Die konkreten Endpoints kapselt js/transitous.js.
@@ -18,6 +24,7 @@
// Zentral hier deklariert, damit KEIN Modul auf Variablen aus // Zentral hier deklariert, damit KEIN Modul auf Variablen aus
// später geladenen Dateien angewiesen ist. // später geladenen Dateien angewiesen ist.
let currentView = 'home'; let currentView = 'home';
let departuresCount = 20; // Wie viele Abfahrten laden ("Mehr laden" erhöht)
let currentStationLat = null; // Koordinaten der gewählten Station let currentStationLat = null; // Koordinaten der gewählten Station
let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping) let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping)
let liveMap = null; let liveMap = null;
@@ -247,11 +254,40 @@
return isFavorite(stationId); return isFavorite(stationId);
} }
// Favoriten auf dem Home-Screen rendern // Favoriten rendern - an ZWEI Stellen:
// 1) Abfahrten-View: ALLE Favoriten als wischbare Chip-Reihe
// (1-Tap-Wechsel zwischen Stationen, aktive Station markiert)
// 2) Startseite: kompakt nur die Top 3 + "Alle anzeigen"
function renderFavorites() { function renderFavorites() {
const favorites = getFavorites();
// --- Abfahrten-View: Chips ---
const chipsContainer = document.getElementById('departureFavorites');
if (chipsContainer) {
if (favorites.length === 0) {
chipsContainer.innerHTML = '';
chipsContainer.style.display = 'none';
} else {
chipsContainer.style.display = 'block';
chipsContainer.innerHTML = `
<div class="filter-label">⭐ Deine Stationen</div>
<div class="filter-chips">
${favorites.map(f => `
<button class="fav-chip ${f.id === currentStationId ? 'active' : ''}"
data-id="${escapeHtml(f.id)}" data-name="${escapeHtml(f.name)}"
data-lat="${f.lat != null ? f.lat : ''}" data-lon="${f.lon != null ? f.lon : ''}"
aria-label="Abfahrten für ${escapeHtml(f.name)} anzeigen">
${escapeHtml(f.name)}
</button>
`).join('')}
</div>
`;
}
}
// --- Startseite: kompakte Top 3 ---
const container = document.getElementById('homeFavorites'); const container = document.getElementById('homeFavorites');
if (!container) return; if (!container) return;
const favorites = getFavorites();
if (favorites.length === 0) { if (favorites.length === 0) {
container.innerHTML = ''; container.innerHTML = '';
@@ -259,11 +295,14 @@
return; return;
} }
const top = favorites.slice(0, 3);
const rest = favorites.length - top.length;
container.style.display = ''; container.style.display = '';
container.innerHTML = ` container.innerHTML = `
<div class="home-favorites-title">⭐ Deine Stationen</div> <div class="home-favorites-title">⭐ Deine Stationen</div>
<div class="home-favorites-list"> <div class="home-favorites-list">
${favorites.map(f => ` ${top.map(f => `
<button class="home-favorite-item" data-id="${escapeHtml(f.id)}" data-name="${escapeHtml(f.name)}" <button class="home-favorite-item" data-id="${escapeHtml(f.id)}" data-name="${escapeHtml(f.name)}"
data-lat="${f.lat != null ? f.lat : ''}" data-lon="${f.lon != null ? f.lon : ''}" data-lat="${f.lat != null ? f.lat : ''}" data-lon="${f.lon != null ? f.lon : ''}"
aria-label="Abfahrten für ${escapeHtml(f.name)} anzeigen"> aria-label="Abfahrten für ${escapeHtml(f.name)} anzeigen">
@@ -271,6 +310,12 @@
<span class="home-favorite-arrow" aria-hidden="true">→</span> <span class="home-favorite-arrow" aria-hidden="true">→</span>
</button> </button>
`).join('')} `).join('')}
${rest > 0 ? `
<button class="home-favorite-item home-favorites-more" id="homeShowAllFavs"
aria-label="Alle Favoriten in der Abfahrten-Ansicht zeigen">
<span class="home-favorite-name">Alle ${favorites.length} Stationen anzeigen</span>
<span class="home-favorite-arrow" aria-hidden="true">→</span>
</button>` : ''}
</div> </div>
`; `;
} }
+56 -9
View File
@@ -516,7 +516,18 @@
} }
// "Mehr Abfahrten laden" (Event-Delegation: Button wird bei jedem
// Render neu erzeugt, der Handler hier überlebt das)
departuresContainer.addEventListener('click', (e) => {
if (e.target.closest('#loadMoreDepartures')) {
departuresCount = Math.min(60, departuresCount + 20);
if (navigator.vibrate) navigator.vibrate(10);
loadDepartures();
}
});
function selectStation(stationId, stationName, lat = null, lon = null) { function selectStation(stationId, stationName, lat = null, lon = null) {
departuresCount = 20; // Neue Station -> wieder mit 20 Abfahrten starten
currentStationId = stationId; currentStationId = stationId;
currentStationName = stationName; currentStationName = stationName;
currentStationLat = lat; currentStationLat = lat;
@@ -529,6 +540,7 @@
// Station für den nächsten App-Start merken // Station für den nächsten App-Start merken
storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName, lat, lon }); storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName, lat, lon });
updateFavoriteButton(); updateFavoriteButton();
renderFavorites(); // Aktiv-Markierung der Favoriten-Chips auffrischen
// Haptic feedback // Haptic feedback
if (navigator.vibrate) { if (navigator.vibrate) {
@@ -591,7 +603,7 @@
const data = await transitousDepartures({ const data = await transitousDepartures({
id: currentStationId, name: currentStationName, id: currentStationId, name: currentStationName,
lat: currentStationLat, lon: currentStationLon lat: currentStationLat, lon: currentStationLon
}); }, departuresCount);
displayDepartures(data.departures || []); displayDepartures(data.departures || []);
saveDeparturesCache(currentStationId, data.departures || []); saveDeparturesCache(currentStationId, data.departures || []);
noteRefreshSuccess(); noteRefreshSuccess();
@@ -636,13 +648,18 @@
const data = await transitousDepartures({ const data = await transitousDepartures({
id: currentStationId, name: currentStationName, id: currentStationId, name: currentStationName,
lat: currentStationLat, lon: currentStationLon lat: currentStationLat, lon: currentStationLon
}); }, departuresCount);
const departures = data.departures || []; const fullDepartures = data.departures || [];
allDepartures = fullDepartures;
const departures = (typeof applyDepartureFilter === 'function')
? applyDepartureFilter(fullDepartures) : fullDepartures;
const now = new Date(); const now = new Date();
// In-Place-Update: nur Zeiten/Verspätungen im bestehenden DOM // In-Place-Update: nur Zeiten/Verspätungen im bestehenden DOM
// aktualisieren (kein Flackern, Scroll-Position bleibt). // aktualisieren (kein Flackern, Scroll-Position bleibt).
// Hat sich die Fahrten-Menge geändert -> voller Re-Render. // WICHTIG: Vergleich gegen die GEFILTERTE Sicht - vorher hat
// ein aktiver Filter den Vergleich immer scheitern lassen und
// der Auto-Refresh hat alle 30s die UNGEFILTERTE Liste gerendert.
const items = departuresContainer.querySelectorAll('.departure-item'); const items = departuresContainer.querySelectorAll('.departure-item');
const domTripIds = Array.from(items).map(i => i.dataset.tripId); const domTripIds = Array.from(items).map(i => i.dataset.tripId);
const newTripIds = departures.map(d => String(d.tripId)); const newTripIds = departures.map(d => String(d.tripId));
@@ -651,7 +668,7 @@
domTripIds.every((id, i) => id === newTripIds[i]); domTripIds.every((id, i) => id === newTripIds[i]);
if (!sameSet) { if (!sameSet) {
displayDepartures(departures); displayDepartures(fullDepartures);
} else { } else {
departures.forEach((dep, i) => { departures.forEach((dep, i) => {
const item = items[i]; const item = items[i];
@@ -673,7 +690,7 @@
} }
} }
saveDeparturesCache(currentStationId, departures); saveDeparturesCache(currentStationId, fullDepartures);
noteRefreshSuccess(); noteRefreshSuccess();
} catch (error) { } catch (error) {
@@ -682,8 +699,17 @@
} }
} }
function displayDepartures(departures) { function displayDepartures(fullDepartures) {
if (departures.length === 0) { // Volle Liste für den Abfahrten-Filter merken (Zustand in extras.js)
allDepartures = fullDepartures;
// Filterleiste nur zeigen, wenn es überhaupt Abfahrten gibt
const filtersEl = document.getElementById('departureFilters');
if (filtersEl) {
filtersEl.style.display = fullDepartures.length > 0 ? 'block' : 'none';
}
if (fullDepartures.length === 0) {
departuresContainer.innerHTML = ` departuresContainer.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-icon">🚉</div> <div class="empty-state-icon">🚉</div>
@@ -693,6 +719,20 @@
return; return;
} }
// Aktiven Verkehrsmittel-Filter anwenden
const departures = (typeof applyDepartureFilter === 'function')
? applyDepartureFilter(fullDepartures) : fullDepartures;
if (departures.length === 0) {
departuresContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div>Keine Abfahrten mit diesem Filter</div>
</div>
`;
return;
}
const now = new Date(); const now = new Date();
const stationInfoHTML = ` const stationInfoHTML = `
<div class="station-info"> <div class="station-info">
@@ -746,7 +786,14 @@
`; `;
}).join(''); }).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 ? `
<button class="btn btn-full-width load-more-btn" id="loadMoreDepartures">
⏬ Mehr Abfahrten laden
</button>` : '';
departuresContainer.innerHTML = stationInfoHTML + departuresHTML + loadMoreHTML;
// Event-Listener für Departure-Items // Event-Listener für Departure-Items
departuresContainer.querySelectorAll('.departure-item').forEach(item => { departuresContainer.querySelectorAll('.departure-item').forEach(item => {
+53 -110
View File
@@ -101,25 +101,21 @@ document.addEventListener('click', (e) => {
} }
}); });
function filterDepartures() { // Reine Filterfunktion - wird von displayDepartures (app.js) beim
if (!allDepartures || allDepartures.length === 0) return; // Rendern angewendet. Der Filter-Zustand lebt hier.
function applyDepartureFilter(departures) {
let filtered = allDepartures; if (activeDepartureFilters.has('all')) return departures;
return departures.filter(dep => {
if (!activeDepartureFilters.has('all')) {
filtered = allDepartures.filter(dep => {
const product = dep.line?.product?.toLowerCase() || ''; const product = dep.line?.product?.toLowerCase() || '';
return activeDepartureFilters.has(product); return activeDepartureFilters.has(product);
}); });
} }
// Container neu rendern // Filter geändert -> mit der vollen Liste neu rendern
const container = document.getElementById('departuresContainer'); // (displayDepartures filtert selbst; so geht nie Datenbestand verloren)
if (filtered.length === 0) { function filterDepartures() {
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #FFED00;">Keine Abfahrten mit diesem Filter</div>'; if (!allDepartures || allDepartures.length === 0) return;
} else { displayDepartures(allDepartures);
displayDepartures(filtered);
}
} }
// ========================================== // ==========================================
@@ -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. // HINWEIS: Der frühere "searchJourneys"-Patch wurde entfernt.
// Die Funktion existierte nie und verursachte einen ReferenceError, // Die Funktion existierte nie und verursachte einen ReferenceError,
// der das restliche Script abgebrochen hat. // der das restliche Script abgebrochen hat.
@@ -440,6 +408,23 @@ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
// APP-START: GESPEICHERTEN ZUSTAND WIEDERHERSTELLEN // APP-START: GESPEICHERTEN ZUSTAND WIEDERHERSTELLEN
// ========================================== // ==========================================
document.addEventListener('DOMContentLoaded', () => { 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 // Favoriten auf dem Home-Screen anzeigen
renderFavorites(); renderFavorites();
@@ -466,8 +451,27 @@ document.addEventListener('DOMContentLoaded', () => {
checkJourneySearchReady(); 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) => { 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'); const favItem = e.target.closest('.home-favorite-item');
if (favItem) { if (favItem) {
switchView('departures'); switchView('departures');
@@ -531,75 +535,14 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// ========================================== // ==========================================
// HOVER-EXTRAS (bereinigt) // HOVER-EXTRAS: KOMPLETT ENTFERNT (v37.1)
// Frühere Version zeigte FAKE-Daten (zufällige Gleisnummern, // - Nav-Quick-Infos: standen als doppelte Beschriftung im Menü
// erfundene "Genauigkeit ±10m", Alert-Attrappen für Favorit/Teilen/ // - Search-Hints ("Tippe mindestens 2 Zeichen"): hatten kein CSS und
// Export/Kalender) und hatte einen Scope-Bug im Search-Hint, // klebten als permanenter Rohtext unter den Suchfeldern
// der bei jedem Tastendruck einen ReferenceError warf. // - 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 // Quick Actions an Abfahrten: ECHTE Funktionen statt Alert-Attrappen
// (Event-Delegation statt MutationObserver - robuster und günstiger) // (Event-Delegation statt MutationObserver - robuster und günstiger)
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
+74 -1
View File
@@ -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 // Aus den gespeicherten Segmenten die AKTUELLEN Positionen berechnen
// und rendern. Läuft jede Sekunde lokal - null API-Kosten. // und rendern. Läuft jede Sekunde lokal - null API-Kosten.
function renderVehiclesFromSegments() { function renderVehiclesFromSegments() {
@@ -250,6 +306,8 @@
const movements = []; const movements = [];
for (const seg of liveMapSegments) { for (const seg of liveMapSegments) {
// Fokus-Modus: nur die angetippte Fahrt rendern
if (focusedVehicleTripId && seg.tripId !== focusedVehicleTripId) continue;
const pos = segmentPositionAt(seg, nowMs); const pos = segmentPositionAt(seg, nowMs);
if (!pos) continue; // Fahrzeug (noch) nicht auf diesem Segment unterwegs if (!pos) continue; // Fahrzeug (noch) nicht auf diesem Segment unterwegs
movements.push({ movements.push({
@@ -278,13 +336,19 @@
return activeVehicleTypes.has(vehicleType); return activeVehicleTypes.has(vehicleType);
}); });
// Update Counter mit Live-Indikator // Update Counter mit Live-Indikator (bzw. Fokus-Hinweis)
const countEl = document.getElementById('vehicleCount'); const countEl = document.getElementById('vehicleCount');
if (countEl) { if (countEl) {
if (focusedVehicleTripId) {
const focusName = filteredMovements[0]?.line?.name || '';
countEl.innerHTML =
`🎯 <span style="color: #fff;">${escapeHtml(focusName)}</span> im Fokus – Popup schließen zum Beenden`;
} else {
countEl.innerHTML = countEl.innerHTML =
`<span class="live-dot" aria-hidden="true"></span> ` + `<span class="live-dot" aria-hidden="true"></span> ` +
`<span style="color: #fff;">${filteredMovements.length}</span> Fahrzeuge live`; `<span style="color: #fff;">${filteredMovements.length}</span> Fahrzeuge live`;
} }
}
const seenTripIds = new Set(); const seenTripIds = new Set();
@@ -342,6 +406,9 @@
const marker = L.marker([lat, lon], { icon: icon }); const marker = L.marker([lat, lon], { icon: icon });
marker.bindPopup(popupContent); marker.bindPopup(popupContent);
// Popup auf = Strecke dieser Fahrt einzeichnen (in Linienfarbe)
marker.on('popupopen', () => showVehicleRoute(tripId));
marker.on('popupclose', hideVehicleRoute);
marker.addTo(liveMap); marker.addTo(liveMap);
vehicleMarkerMap.set(tripId, { marker, lineName }); vehicleMarkerMap.set(tripId, { marker, lineName });
}); });
@@ -354,6 +421,12 @@
vehicleMarkerMap.delete(tripId); 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 // Filter Buttons mit Event Delegation
+37 -5
View File
@@ -135,10 +135,10 @@ async function transitousStopQuery({ id, name, lat, lon }) {
// ========================================== // ==========================================
// ABFAHRTEN (stoptimes -> HAFAS-departures-Form) // ABFAHRTEN (stoptimes -> HAFAS-departures-Form)
// ========================================== // ==========================================
async function transitousDepartures(station) { async function transitousDepartures(station, n = 20) {
const stopQuery = await transitousStopQuery(station); const stopQuery = await transitousStopQuery(station);
// 15s Cache: schützt vor Refresh-Spam // 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 departures = (data.stopTimes || []).map(st => {
const p = st.place || {}; const p = st.place || {};
@@ -294,6 +294,31 @@ async function transitousTrip(tripId) {
return { trip: { stopovers } }; 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) // RADAR / LIVE-MAP (map/trips -> HAFAS-radar-Form)
// //
@@ -303,8 +328,10 @@ async function transitousTrip(tripId) {
// Transitous-Web-Karte selbst auch). // Transitous-Web-Karte selbst auch).
// ========================================== // ==========================================
// Google-Polyline dekodieren (Precision 5 laut MOTIS-Spec für map/trips) // Google-Polyline dekodieren. Die Precision variiert je Endpoint:
function decodePolyline5(encoded) { // 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 = []; const points = [];
let index = 0, lat = 0, lon = 0; let index = 0, lat = 0, lon = 0;
while (index < encoded.length) { while (index < encoded.length) {
@@ -318,11 +345,16 @@ function decodePolyline5(encoded) {
const delta = (result & 1) ? ~(result >> 1) : (result >> 1); const delta = (result & 1) ? ~(result >> 1) : (result >> 1);
if (which === 0) lat += delta; else lon += delta; if (which === 0) lat += delta; else lon += delta;
} }
points.push([lat / 1e5, lon / 1e5]); points.push([lat / factor, lon / factor]);
} }
return points; return points;
} }
// Kompatibilitäts-Alias (Tests + map/trips)
function decodePolyline5(encoded) {
return decodePolyline(encoded, 5);
}
async function transitousRadarSegments(bounds, zoom) { async function transitousRadarSegments(bounds, zoom) {
const now = new Date(); const now = new Date();
const path = `/v6/map/trips?zoom=${Math.round(zoom)}` + const path = `/v6/map/trips?zoom=${Math.round(zoom)}` +
+7 -3
View File
@@ -1,11 +1,13 @@
{ {
"name": "vbb-netz-status", "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", "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", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "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": [ "keywords": [
"vbb", "vbb",
@@ -33,7 +35,9 @@
"dependencies": { "dependencies": {
"mime-types": "^2.1.35" "mime-types": "^2.1.35"
}, },
"devDependencies": {}, "devDependencies": {
"jsdom": "^24.0.0"
},
"engines": { "engines": {
"node": ">=16.0.0", "node": ">=16.0.0",
"npm": ">=8.0.0" "npm": ">=8.0.0"
+1 -1
View File
@@ -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! // App-Shell: nur Dateien, die wirklich existieren!
// (Der alte SW listete eine nicht existierende Font-Datei – dadurch // (Der alte SW listete eine nicht existierende Font-Datei – dadurch
+144 -38
View File
@@ -1017,11 +1017,6 @@
padding: 20px; padding: 20px;
} }
.detail-modal {
left: 50%;
transform: translateX(-50%);
max-width: 600px;
}
} }
/* Pull-to-refresh styles */ /* Pull-to-refresh styles */
@@ -1352,17 +1347,6 @@
padding: 30px 40px; 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 auch größer */
.journey-result { .journey-result {
padding: 30px; padding: 30px;
@@ -1376,17 +1360,9 @@
/* Extra Large Desktop */ /* Extra Large Desktop */
@media (min-width: 1600px) { @media (min-width: 1600px) {
.container { .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 */ /* Mobile Optimierungen */
@@ -2252,10 +2228,6 @@
========================================== */ ========================================== */
.departure-filters, .journey-transport-filters { .departure-filters, .journey-transport-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px; padding: 12px;
background-color: #0a0a0a; background-color: #0a0a0a;
border-radius: 8px; border-radius: 8px;
@@ -2266,7 +2238,14 @@
color: #FFED00; color: #FFED00;
font-size: 13px; font-size: 13px;
font-weight: bold; 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 { .filter-btn, .transport-filter-btn {
@@ -2282,10 +2261,8 @@
flex-shrink: 0; flex-shrink: 0;
} }
.transport-filter-btn { /* (Sondergröße entfernt - Journey-Chips waren doppelt so groß
font-size: 20px; wie alle anderen Filter und brachen unruhig um) */
padding: 8px 12px;
}
.filter-btn.active, .transport-filter-btn.active { .filter-btn.active, .transport-filter-btn.active {
background-color: #FFED00; background-color: #FFED00;
@@ -3953,11 +3930,140 @@ select:focus-visible {
min-width: 76px; min-width: 76px;
} }
.detail-modal {
max-width: 720px;
}
.livemap-container { .livemap-container {
height: calc(100vh - 100px); 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;
}
+176
View File
@@ -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);
})();
+265
View File
@@ -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(/<script[^>]*src=[^>]*><\/script>/g, '')
.replace(/<link[^>]*>/g, '');
const errors = [];
const dom = new JSDOM(cleanHtml, {
url: 'https://localhost:3000/',
runScripts: 'outside-only',
pretendToBeVisual: true
});
const { window } = dom;
window.addEventListener('error', (e) => errors.push('window.error: ' + e.message));
// --- Mocks ---
let lastJourneyUrl = null;
window.fetch = (url) => {
const u = String(url);
if (u.includes('/plan')) lastJourneyUrl = u;
return Promise.resolve({
ok: true,
json: () => Promise.resolve(
u.includes('/geocode') ? []
: u.includes('/stoptimes') ? { stopTimes: [], place: {} }
: u.includes('/plan') ? { itineraries: [] }
: u.includes('/map/') ? []
: {}
),
text: () => Promise.resolve('# Changelog\n## v35')
});
};
window.navigator.vibrate = () => true;
const mm = () => ({ matches: false, addEventListener(){}, removeEventListener(){}, addListener(){}, removeListener(){} });
window.matchMedia = mm;
const fakeLayer = {
addTo: () => fakeLayer, remove: () => {}, bindPopup: () => fakeLayer,
on: () => fakeLayer, setLatLng: () => fakeLayer, setPopupContent: () => fakeLayer
};
window.L = {
polyline: () => fakeLayer,
map: () => ({
setView(){ return this; }, on(){ return this; }, invalidateSize(){}, getZoom: () => 12,
getBounds: () => ({ getNorth:()=>52.6, getSouth:()=>52.4, getEast:()=>13.6, getWest:()=>13.2 })
}),
tileLayer: () => fakeLayer, marker: () => fakeLayer, divIcon: () => ({})
};
Object.defineProperty(window.navigator, 'serviceWorker', {
value: { register: () => Promise.resolve({}) }, configurable: true
});
// --- Module in Ladereihenfolge ausführen ---
// Verkettet ausgeführt: entspricht dem gemeinsamen globalen Scope
// klassischer <script>-Tags. Test-Hook (__t) nutzt direktes eval,
// um auf den Modul-Scope zugreifen zu können.
const combined = moduleFiles
.map(f => fs.readFileSync(path.join(dir, f), 'utf8'))
.join('\n;\n') + '\n;window.__t = (c) => eval(c);';
try {
window.eval(combined);
console.log('✅ Alle 5 Module geladen (verketteter Scope)');
} catch (e) {
errors.push('Module: ' + e.message);
console.log('❌ Module:', e.message);
}
try {
window.document.dispatchEvent(new window.Event('DOMContentLoaded', { bubbles: true }));
console.log('✅ DOMContentLoaded ohne Fehler');
} catch (e) {
errors.push('DOMContentLoaded: ' + e.message);
}
setTimeout(() => {
const doc = window.document;
// 1) Views durchklicken
doc.querySelectorAll('.nav-item').forEach(item => {
item.click();
const target = doc.getElementById('view-' + item.dataset.view);
if (!target?.classList.contains('active')) errors.push('View kaputt: ' + item.dataset.view);
});
console.log('✅ Alle 5 Views schaltbar');
// 2) Favoriten: toggle + Rendering auf Home
window.__t(`
currentStationId = '900000100003';
currentStationName = 'S+U Alexanderplatz';
toggleFavorite(currentStationId, currentStationName);
`);
const favItems = doc.querySelectorAll('.home-favorite-item');
if (favItems.length !== 1) errors.push('Favorit nicht gerendert');
else console.log('✅ Favorit gespeichert & auf Home gerendert:', favItems[0].dataset.name);
// 3) Persistenz: localStorage-Inhalte prüfen
const stored = JSON.parse(window.localStorage.getItem('vbb_favorites') || '[]');
if (stored.length !== 1 || stored[0].name !== 'S+U Alexanderplatz') errors.push('localStorage Favoriten falsch');
else console.log('✅ localStorage enthält Favorit');
// 4) Favorit-Klick -> Abfahrten-View + Station gesetzt
favItems[0]?.click();
if (!doc.getElementById('view-departures').classList.contains('active')) errors.push('Favorit-Klick öffnet Abfahrten nicht');
else console.log('✅ Favorit-Klick öffnet Abfahrten-View');
// 5) Journey: Filter + Zeitwahl in der URL
window.__t(`
journeyFromStation = { id: 'A1', name: 'Start', lat: 52.47, lon: 13.36 };
journeyToStation = { id: 'B2', name: 'Ziel', lat: 52.52, lon: 13.41 };
activeTransportModes.delete('bus');
document.getElementById('journeyTimeInput').value = '2026-07-04T10:30';
document.getElementById('journeyTimeMode').value = 'arrival';
document.getElementById('searchJourneyBtn').disabled = false;
`);
doc.getElementById('searchJourneyBtn').click();
setTimeout(async () => {
if (!lastJourneyUrl) errors.push('Journey-Suche feuerte nicht');
else {
if (lastJourneyUrl.includes('BUS') || !lastJourneyUrl.includes('SUBWAY')) errors.push('Transport-Filter fehlt/falsch in plan-URL');
else console.log('✅ Verkehrsmittel-Filter in plan-URL (ohne BUS)');
if (!lastJourneyUrl.includes('arriveBy=true') || !lastJourneyUrl.includes('time=')) errors.push('Zeitwahl fehlt in plan-URL');
else console.log('✅ Zeitwahl in plan-URL (arriveBy=true)');
}
// 6) Offline-Cache: Abfahrten-Cache in localStorage nach Fehler nutzbar?
const cacheOk = window.__t(`
saveDeparturesCache('TEST', [{when: new Date().toISOString(), line: {name: 'U2'}}]);
const c = loadDeparturesCache('TEST');
c && c.departures.length === 1;
`);
if (!cacheOk) errors.push('Offline-Cache defekt');
else console.log('✅ Offline-Abfahrten-Cache funktioniert');
// 7) Totalausfall: beide APIs down -> Banner erscheint
const origFetch = window.fetch;
window.fetch = () => Promise.reject(new TypeError('NetworkError'));
window.__t(`apiCache.clear();`);
window.__t(`apiFetch('https://v6.vbb.transport.rest/locations?query=test&results=1', { ttl: 0 }).catch(() => {})`);
await new Promise(r => setTimeout(r, 100));
const banner = doc.getElementById('apiOutageBanner');
if (!banner || banner.style.display === 'none') errors.push('Outage-Banner erscheint nicht');
else console.log('✅ Totalausfall beider APIs -> Störungs-Banner erscheint');
// 8) API erholt sich -> Banner verschwindet
window.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
window.__t(`apiFetch('https://v6.vbb.transport.rest/locations?query=test2&results=1', { ttl: 0 }).catch(() => {})`);
await new Promise(r => setTimeout(r, 100));
if (doc.getElementById('apiOutageBanner').style.display !== 'none') errors.push('Banner verschwindet nicht nach Erholung');
else console.log('✅ API-Erholung -> Banner verschwindet automatisch');
window.fetch = origFetch;
// 9) Abfahrten über Transitous (einzige Datenquelle)
window.fetch = (url) => {
const u = String(url);
if (u.includes('api.transitous.org') && u.includes('/stoptimes')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ stopTimes: [{
place: { name: 'Testhalt', departure: '2026-07-06T10:05:00+02:00',
scheduledDeparture: '2026-07-06T10:03:00+02:00', track: '1' },
mode: 'TRAM', headsign: 'Testziel', routeShortName: 'M10',
tripId: 'tt1', realTime: true, cancelled: false, tripCancelled: false
}], place: {} }) });
}
return Promise.reject(new TypeError('NetworkError'));
};
window.__t(`
apiCache.clear();
currentStationId = '900100003'; currentStationName = 'Teststation';
currentStationLat = 52.52; currentStationLon = 13.41;
`);
window.__t('loadDepartures()');
await new Promise(r => setTimeout(r, 200));
const depHtml = doc.getElementById('departuresContainer').innerHTML;
if (!depHtml.includes('M10') || !depHtml.includes('Testziel')) {
errors.push('Abfahrten über Transitous werden nicht gerendert');
} else console.log('✅ Abfahrten kommen über Transitous (stoptimes -> Renderer)');
// 10) Stationssuche über Transitous (geocode)
window.__t(`apiCache.clear();`);
window.fetch = (url) => {
const u = String(url);
if (u.includes('/geocode')) return Promise.resolve({ ok: true, json: () => Promise.resolve([
{ type: 'STOP', name: 'Amrumer Str.', id: 'de-DELFI_de:11000:900009202', lat: 52.542, lon: 13.349, tokens: [], areas: [], score: 1 }
]) });
return Promise.reject(new TypeError('NetworkError'));
};
doc.getElementById('stationSearch').value = 'Amrumer';
doc.getElementById('stationSearch').dispatchEvent(new window.Event('input', { bubbles: true }));
await new Promise(r => setTimeout(r, 600));
const sugHtml = doc.getElementById('suggestions').innerHTML;
if (!sugHtml.includes('Amrumer')) errors.push('Suche über Transitous liefert keine Vorschläge');
else console.log('✅ Stationssuche über Transitous (geocode -> Vorschläge)');
window.fetch = origFetch;
// 11) Abfahrten-Filter: filtert wirklich + Auto-Refresh respektiert Filter
window.__t(`
allDepartures = [];
displayDepartures([
{ tripId: 't1', direction: 'A', when: new Date(Date.now()+300000).toISOString(), line: { name: 'U2', product: 'subway' } },
{ tripId: 't2', direction: 'B', when: new Date(Date.now()+400000).toISOString(), line: { name: '100', product: 'bus' } }
]);
`);
const filterBar = doc.getElementById('departureFilters');
if (filterBar.style.display === 'none') errors.push('Filterleiste erscheint nicht');
else console.log('✅ Filterleiste erscheint bei Abfahrten');
doc.querySelector('.departure-filters .filter-btn[data-filter="subway"]').click();
await new Promise(r => setTimeout(r, 50));
const shown = doc.querySelectorAll('#departuresContainer .departure-item');
const onlySubway = shown.length === 1 && shown[0].dataset.tripId === 't1';
if (!onlySubway) errors.push('Abfahrten-Filter filtert nicht (U-Bahn-Filter zeigt ' + shown.length + ' Items)');
else console.log('✅ Abfahrten-Filter: U-Bahn-Filter zeigt nur U2');
// Mehr-laden-Button vorhanden?
if (!doc.getElementById('loadMoreDepartures')) errors.push('Mehr-laden-Button fehlt');
else console.log('✅ "Mehr Abfahrten laden"-Button gerendert');
// 12) Versions-Konsistenz: APP_VERSION == package.json == SW-Cache + Anzeigen befüllt
const pkgVersion = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
const appVersion = window.__t('APP_VERSION');
const swSrc = fs.readFileSync(path.join(dir, 'service-worker.js'), 'utf8');
if (appVersion !== pkgVersion) errors.push(`Version inkonsistent: APP_VERSION=${appVersion} vs package.json=${pkgVersion}`);
else if (!swSrc.includes(`vbb-status-v${appVersion}`)) errors.push('SW-Cache-Name passt nicht zu APP_VERSION');
else console.log('✅ Version konsistent überall:', appVersion);
const hv = doc.getElementById('homeVersion');
if (!hv || hv.textContent !== 'v' + appVersion) errors.push('homeVersion nicht befüllt');
else console.log('✅ Versions-Anzeigen aus APP_VERSION befüllt');
// 13) Favoriten-Chips in der Abfahrten-View + kompakte Startseite
window.__t(`
localStorage.setItem('vbb_favorites', JSON.stringify([
{ id: 'f1', name: 'Station Eins' }, { id: 'f2', name: 'Station Zwei' },
{ id: 'f3', name: 'Station Drei' }, { id: 'f4', name: 'Station Vier' }
]));
currentStationId = 'f2';
renderFavorites();
`);
const chips = doc.querySelectorAll('#departureFavorites .fav-chip');
const activeChip = doc.querySelector('#departureFavorites .fav-chip.active');
if (chips.length !== 4) errors.push(`Abfahrten-Chips: ${chips.length} statt 4`);
else if (!activeChip || activeChip.dataset.id !== 'f2') errors.push('Aktiver Chip nicht markiert');
else console.log('✅ Favoriten-Chips in Abfahrten-View (4 Chips, aktive markiert)');
const homeItems = doc.querySelectorAll('#homeFavorites .home-favorite-item:not(.home-favorites-more)');
const moreBtn = doc.getElementById('homeShowAllFavs');
if (homeItems.length !== 3 || !moreBtn) errors.push('Startseite nicht kompakt (Top 3 + Alle anzeigen)');
else console.log('✅ Startseite kompakt: Top 3 + "Alle anzeigen"-Button');
console.log('✅ Struktur-Checks abgeschlossen');
if (errors.length === 0) {
console.log('\n🎉 ALLE TESTS BESTANDEN');
process.exit(0);
} else {
console.log('\n❌ FEHLER:');
errors.forEach(e => console.log(' -', e));
process.exit(1);
}
}, 300);
}, 300);