From 693e208cfd36aee92994c941a469cac4c0535894 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:12:43 +0200 Subject: [PATCH] Add files via upload --- CHANGELOG.md | 50 ++++++++ index.html | 47 +++++--- js/api.js | 51 +++++++- js/app.js | 65 +++++++++-- js/extras.js | 169 +++++++++------------------ js/livemap.js | 81 ++++++++++++- js/transitous.js | 42 ++++++- package.json | 12 +- service-worker.js | 2 +- styles.css | 182 +++++++++++++++++++++++------ tests/adapter.test.js | 176 ++++++++++++++++++++++++++++ tests/smoke.test.js | 265 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 950 insertions(+), 192 deletions(-) create mode 100644 tests/adapter.test.js create mode 100644 tests/smoke.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3a527..d9ba43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Schnellere Karte, offizielle Linienfarben, Desktop-Layout diff --git a/index.html b/index.html index de5c8aa..83761aa 100644 --- a/index.html +++ b/index.html @@ -102,7 +102,7 @@

VBB NETZ STATUS

Live-Nahverkehr für Berlin & Brandenburg

-
v36.0
+
@@ -132,6 +132,14 @@

Fahrzeuge in Echtzeit verfolgen

+ @@ -151,6 +159,10 @@
+ + +
@@ -176,12 +188,14 @@
@@ -264,12 +278,14 @@
Verkehrsmittel:
+
+
@@ -324,8 +340,8 @@

🚀 Version Info

- - + +
Version:30.0
Release:10.02.2026
Version:
Release:
Lizenz:MIT
Status:✅ Stabil
@@ -353,12 +369,12 @@
-

📊 Statistiken

+

📊 Architektur

- - - - + + + +
HTML:407 Zeilen
CSS:2689 Zeilen
JavaScript:2161 Zeilen
Gesamt:~5.300 Zeilen Code
Module:6 (api, transitous, app, livemap, changelog, extras)
Datenquelle:Transitous / MOTIS 2
Tests:Adapter- + Smoke-Suite (GitHub Actions)
Code:~8.500 Zeilen (HTML, CSS, JS)
@@ -385,7 +401,7 @@
  • Linien-Filter
  • Detail-Ansicht mit Zwischenhalten
  • -

    API: GET /stops/:id/departures

    +

    API: GET /v6/stoptimes

    @@ -401,7 +417,7 @@
  • Verkehrsmittel-Filter
  • Detail-Ansicht mit allen Haltestellen
  • -

    API: GET /journeys

    +

    API: GET /v6/plan

    @@ -413,11 +429,12 @@
  • Leaflet.js Kartenintegration
  • Dynamische Bounding Box
  • Fahrzeug-Filter (U-Bahn, S-Bahn, Bus, Tram)
  • -
  • BVG-Linienfarben
  • -
  • Auto-Update alle 15s
  • +
  • Offizielle Linienfarben aus dem GTFS-Feed
  • +
  • Strecken-Anzeige + Fokus-Modus per Fahrzeug-Tap
  • +
  • API-Poll alle 30s + lokale 1s-Animation
  • Zoom & Pan optimiert
  • -

    API: GET /radar

    +

    API: GET /v6/map/trips

    @@ -429,10 +446,10 @@
  • Installierbar (Add to Home Screen)
  • Service Worker mit Caching
  • Offline-Modus
  • -
  • Push-Benachrichtigungen (geplant)
  • +
  • Favoriten & Offline-Abfahrten (localStorage)
  • App-Manifest
  • -

    Cache: vbb-status-v36

    +

    Cache:

    diff --git a/js/api.js b/js/api.js index 706a38b..acbe57e 100644 --- a/js/api.js +++ b/js/api.js @@ -4,6 +4,12 @@ // 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 // Datenquelle. transport.rest (VBB/BVG) wurde komplett entfernt. // Die konkreten Endpoints kapselt js/transitous.js. @@ -18,6 +24,7 @@ // Zentral hier deklariert, damit KEIN Modul auf Variablen aus // später geladenen Dateien angewiesen ist. let currentView = 'home'; + let departuresCount = 20; // Wie viele Abfahrten laden ("Mehr laden" erhöht) let currentStationLat = null; // Koordinaten der gewählten Station let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping) let liveMap = null; @@ -247,11 +254,40 @@ 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() { + 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 = ` +
    ⭐ Deine Stationen
    +
    + ${favorites.map(f => ` + + `).join('')} +
    + `; + } + } + + // --- Startseite: kompakte Top 3 --- const container = document.getElementById('homeFavorites'); if (!container) return; - const favorites = getFavorites(); if (favorites.length === 0) { container.innerHTML = ''; @@ -259,11 +295,14 @@ return; } + const top = favorites.slice(0, 3); + const rest = favorites.length - top.length; + container.style.display = ''; container.innerHTML = `
    ⭐ Deine Stationen
    - ${favorites.map(f => ` + ${top.map(f => ` `).join('')} + ${rest > 0 ? ` + ` : ''}
    `; } diff --git a/js/app.js b/js/app.js index fbe60a1..ab8f348 100644 --- a/js/app.js +++ b/js/app.js @@ -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) { + departuresCount = 20; // Neue Station -> wieder mit 20 Abfahrten starten currentStationId = stationId; currentStationName = stationName; currentStationLat = lat; @@ -529,6 +540,7 @@ // Station für den nächsten App-Start merken storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName, lat, lon }); updateFavoriteButton(); + renderFavorites(); // Aktiv-Markierung der Favoriten-Chips auffrischen // Haptic feedback if (navigator.vibrate) { @@ -591,7 +603,7 @@ const data = await transitousDepartures({ id: currentStationId, name: currentStationName, lat: currentStationLat, lon: currentStationLon - }); + }, departuresCount); displayDepartures(data.departures || []); saveDeparturesCache(currentStationId, data.departures || []); noteRefreshSuccess(); @@ -636,13 +648,18 @@ const data = await transitousDepartures({ id: currentStationId, name: currentStationName, lat: currentStationLat, lon: currentStationLon - }); - const departures = data.departures || []; + }, departuresCount); + const fullDepartures = data.departures || []; + allDepartures = fullDepartures; + const departures = (typeof applyDepartureFilter === 'function') + ? applyDepartureFilter(fullDepartures) : fullDepartures; const now = new Date(); // In-Place-Update: nur Zeiten/Verspätungen im bestehenden DOM // 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 domTripIds = Array.from(items).map(i => i.dataset.tripId); const newTripIds = departures.map(d => String(d.tripId)); @@ -651,7 +668,7 @@ domTripIds.every((id, i) => id === newTripIds[i]); if (!sameSet) { - displayDepartures(departures); + displayDepartures(fullDepartures); } else { departures.forEach((dep, i) => { const item = items[i]; @@ -673,7 +690,7 @@ } } - saveDeparturesCache(currentStationId, departures); + saveDeparturesCache(currentStationId, fullDepartures); noteRefreshSuccess(); } catch (error) { @@ -682,8 +699,17 @@ } } - function displayDepartures(departures) { - if (departures.length === 0) { + function displayDepartures(fullDepartures) { + // 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 = `
    🚉
    @@ -693,6 +719,20 @@ return; } + // Aktiven Verkehrsmittel-Filter anwenden + const departures = (typeof applyDepartureFilter === 'function') + ? applyDepartureFilter(fullDepartures) : fullDepartures; + + if (departures.length === 0) { + departuresContainer.innerHTML = ` +
    +
    🔍
    +
    Keine Abfahrten mit diesem Filter
    +
    + `; + 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 ? ` + ` : ''; + + 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(/]*src=[^>]*><\/script>/g, '') + .replace(/]*>/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