// VBB Netz Status - API-Schicht, Rate-Limiting, Cache, Persistenz, Favoriten // ========================================== // 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.5.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. let currentStationId = null; let currentStationName = null; let searchTimeout = null; let touchStartY = 0; let isPulling = false; let autoRefreshInterval = null; // --- Geteilter App-Zustand (von mehreren Modulen genutzt) --- // 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 lastKnownLat = null; // Letzte bekannte GPS-Position let lastKnownLon = null; // (für Geocode-Bias + Karten-Zentrierung) let currentStationLat = null; // Koordinaten der gewählten Station let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping) let liveMap = null; let liveMapUpdateInterval = null; let activeVehicleTypes = new Set(['all']); let debounceTimer = null; let liveMapSegments = []; // Fahrt-Segmente von Transitous (für lokale Animation) let liveMapAnimationInterval = null; // 1s-Loop: Positionen lokal interpolieren const vehicleMarkerMap = new Map(); // tripId -> { marker, lineName } let activeTransportModes = new Set(['subway', 'suburban', 'tram', 'bus', 'regional', 'express']); // ========================================== // ZENTRALE API-SCHICHT // - Rate-Limit: max. 90 Requests/Minute (API erlaubt 100, wir lassen Puffer) // - Response-Cache mit TTL pro Endpoint (spart massiv Requests) // - Dedupe: identische parallele Requests werden nur 1x gefeuert // ========================================== const API_SOFT_LIMIT = 90; // Max. Requests pro Minute (Budget) const apiCallTimestamps = []; // Zeitstempel echter Requests const apiCache = new Map(); // url -> { data, time, ttl } const apiInflight = new Map(); // url -> Promise (laufende Requests) const API_CACHE_MAX_ENTRIES = 300; // Speicher begrenzen function pruneApiTimestamps() { const cutoff = Date.now() - 60000; while (apiCallTimestamps.length && apiCallTimestamps[0] <= cutoff) { apiCallTimestamps.shift(); } } // Wie viele Requests sind in der aktuellen Minute noch frei? function apiBudgetLeft() { pruneApiTimestamps(); return API_SOFT_LIMIT - apiCallTimestamps.length; } // Nicht-verbrauchende Prüfung (für UI-Vorabchecks) function canMakeApiCall() { return apiBudgetLeft() > 0; } /** * Zentraler API-Aufruf. * @param {string} url - vollständige URL * @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) } * @returns {Promise} JSON-Daten * Wirft Error('RATE_LIMIT') wenn Budget aufgebraucht (Cache wird trotzdem genutzt). */ /** * Zentraler API-Aufruf. * - Budget: max. 90 Requests/Minute (Selbstschutz gegenüber Transitous) * - Response-Cache mit TTL, Dedupe paralleler identischer Requests * - Timeout 12s pro Request * - Störungs-Banner bei Ausfall, automatisches Ausblenden bei Erholung * - Bei Fehler oder leerem Budget wird notfalls abgelaufener Cache geliefert * * @param {string} url - vollständige URL * @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) } */ async function apiFetch(url, { ttl = 0 } = {}) { // 1) Frischer Cache-Treffer? -> kein Request nötig const cached = apiCache.get(url); if (cached && (Date.now() - cached.time) < cached.ttl) { return cached.data; } // 2) Läuft derselbe Request bereits? -> Promise wiederverwenden if (apiInflight.has(url)) { return apiInflight.get(url); } // 3) Budget prüfen; wenn leer, notfalls abgelaufenen Cache liefern if (apiBudgetLeft() <= 0) { if (cached) { console.warn('Rate-Limit erreicht – liefere veraltete Cache-Daten für', url); return cached.data; } throw new Error('RATE_LIMIT'); } apiCallTimestamps.push(Date.now()); const promise = (async () => { try { const response = await fetchWithTimeout(url, 12000); if (!response.ok) { const err = new Error('HTTP ' + response.status); // 4xx = Anfragefehler, kein API-Ausfall -> kein Banner if (response.status >= 500) showApiOutageBanner(); throw err; } const data = await response.json(); if (ttl > 0) { // Cache-Größe begrenzen (ältesten Eintrag entfernen) if (apiCache.size >= API_CACHE_MAX_ENTRIES) { const oldestKey = apiCache.keys().next().value; apiCache.delete(oldestKey); } apiCache.set(url, { data, time: Date.now(), ttl }); } hideApiOutageBanner(); // API antwortet -> alles gut return data; } catch (e) { if (!/^HTTP 4/.test(e.message || '')) { // Netzwerkfehler/Timeout/5xx -> Störung anzeigen, // notfalls alten Cache liefern showApiOutageBanner(); if (cached) { console.warn('API nicht erreichbar – liefere veraltete Cache-Daten'); return cached.data; } } throw e; } })().finally(() => apiInflight.delete(url)); apiInflight.set(url, promise); return promise; } // fetch mit hartem Timeout (hängende Verbindungen brechen sauber ab) async function fetchWithTimeout(realUrl, ms) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); try { return await fetch(realUrl, { signal: controller.signal }); } finally { clearTimeout(timer); } } // ========================================== // GLOBALES STÖRUNGS-BANNER // Erscheint, wenn primäre UND Fallback-API versagen // (z.B. Wartung/Ausfall bei Transitous). // Verschwindet automatisch beim nächsten erfolgreichen Request. // ========================================== function showApiOutageBanner() { let banner = document.getElementById('apiOutageBanner'); if (!banner) { banner = document.createElement('div'); banner.id = 'apiOutageBanner'; banner.className = 'api-outage-banner'; banner.setAttribute('role', 'alert'); banner.innerHTML = '⚠️ Datenanbieter gestört – Transitous ' + '(transitous.org) ist aktuell nicht erreichbar. ' + 'Die App versucht es automatisch weiter.'; document.body.prepend(banner); } banner.style.display = ''; } function hideApiOutageBanner() { const banner = document.getElementById('apiOutageBanner'); if (banner) banner.style.display = 'none'; } // HTML-Escaping für alle Strings aus der API (XSS-Schutz) function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ========================================== // LOKALE PERSISTENZ (localStorage) // ========================================== const STORAGE_KEYS = { favorites: 'vbb_favorites', favRoutes: 'vbb_fav_routes', lastStation: 'vbb_last_station', lastJourney: 'vbb_last_journey', lastPosition: 'vbb_last_position', depCachePrefix: 'vbb_dep_cache_', stopMapPrefix: 'vbb_transitous_stop_' }; function storageGet(key, fallback = null) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (e) { return fallback; } } function storageSet(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { // Quota voll oder localStorage blockiert -> App läuft ohne Persistenz weiter console.warn('localStorage nicht verfügbar:', e.message); } } // --- Favoriten --- const MAX_FAVORITES = 10; function getFavorites() { return storageGet(STORAGE_KEYS.favorites, []); } function isFavorite(stationId) { return getFavorites().some(f => f.id === stationId); } function toggleFavorite(stationId, stationName) { let favorites = getFavorites(); if (favorites.some(f => f.id === stationId)) { favorites = favorites.filter(f => f.id !== stationId); } else { favorites.unshift({ id: stationId, name: stationName, lat: currentStationLat, lon: currentStationLon }); favorites = favorites.slice(0, MAX_FAVORITES); } storageSet(STORAGE_KEYS.favorites, favorites); renderFavorites(); updateFavoriteButton(); return isFavorite(stationId); } // 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; if (favorites.length === 0) { container.innerHTML = ''; container.style.display = 'none'; return; } const top = favorites.slice(0, 3); const rest = favorites.length - top.length; container.style.display = ''; container.innerHTML = `
⭐ Deine Stationen
${top.map(f => ` `).join('')} ${rest > 0 ? ` ` : ''}
`; } // Stern-Button in der Abfahrten-View aktualisieren function updateFavoriteButton() { const btn = document.getElementById('favoriteBtn'); if (!btn) return; if (!currentStationId) { btn.style.display = 'none'; return; } btn.style.display = ''; const fav = isFavorite(currentStationId); btn.textContent = fav ? '⭐' : '☆'; btn.setAttribute('aria-pressed', String(fav)); btn.title = fav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'; } // --- Routen-Favoriten (Pendler: 1-Tap-Suche) --- const MAX_FAV_ROUTES = 5; function getFavRoutes() { return storageGet(STORAGE_KEYS.favRoutes, []); } function routeKey(from, to) { return `${from?.id}→${to?.id}`; } function isFavRoute(from, to) { const key = routeKey(from, to); return getFavRoutes().some(r => routeKey(r.from, r.to) === key); } function toggleFavRoute(from, to) { if (!from || !to) return false; let routes = getFavRoutes(); const key = routeKey(from, to); if (routes.some(r => routeKey(r.from, r.to) === key)) { routes = routes.filter(r => routeKey(r.from, r.to) !== key); } else { routes.unshift({ from, to }); routes = routes.slice(0, MAX_FAV_ROUTES); } storageSet(STORAGE_KEYS.favRoutes, routes); renderRouteFavorites(); return isFavRoute(from, to); } // Kurzname für Chips: "S+U Alexanderplatz (Berlin)" -> "Alexanderplatz" function shortStationName(name) { return String(name || '') .replace(/\s*\(Berlin\)\s*/g, '') .replace(/^(S\+U|S|U|Bhf\.?)\s+/i, '') .trim(); } function renderRouteFavorites() { const container = document.getElementById('journeyFavorites'); if (!container) return; const routes = getFavRoutes(); if (routes.length === 0) { container.innerHTML = ''; container.style.display = 'none'; return; } container.style.display = 'block'; container.innerHTML = `
⭐ Deine Routen
${routes.map((r, i) => ` `).join('')}
`; } // --- Letzte bekannte Position (für Geocode-Bias) --- function rememberPosition(lat, lon) { lastKnownLat = lat; lastKnownLon = lon; storageSet(STORAGE_KEYS.lastPosition, { lat, lon, time: Date.now() }); } function restorePosition() { const pos = storageGet(STORAGE_KEYS.lastPosition); // Nur nutzen, wenn jünger als 1 Stunde (sonst evtl. ganz woanders) if (pos && Date.now() - pos.time < 60 * 60 * 1000) { lastKnownLat = pos.lat; lastKnownLon = pos.lon; } } // --- localStorage-Aufräumung beim App-Start --- // Abfahrten-Caches und Geocode-Mappings wuchsen bisher unbegrenzt. function cleanupStorage() { const now = Date.now(); const WEEK = 7 * 24 * 60 * 60 * 1000; const MONTH = 30 * 24 * 60 * 60 * 1000; const toDelete = []; try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(STORAGE_KEYS.depCachePrefix)) { const entry = storageGet(key); if (!entry || !entry.time || now - entry.time > WEEK) toDelete.push(key); } else if (key.startsWith(STORAGE_KEYS.stopMapPrefix)) { const entry = storageGet(key); // Altes Format (reiner String, ohne Zeitstempel) -> migrieren if (typeof entry === 'string') { storageSet(key, { id: entry, time: now }); } else if (!entry || !entry.time || now - entry.time > MONTH) { toDelete.push(key); } } } toDelete.forEach(k => localStorage.removeItem(k)); if (toDelete.length > 0) { console.warn(`Storage-Aufräumung: ${toDelete.length} alte Einträge entfernt`); } } catch (e) { /* localStorage nicht verfügbar */ } } // --- Abfahrten-Cache für Offline-Modus --- function saveDeparturesCache(stationId, departures) { storageSet(STORAGE_KEYS.depCachePrefix + stationId, { departures, time: Date.now() }); } function loadDeparturesCache(stationId) { return storageGet(STORAGE_KEYS.depCachePrefix + stationId, null); } function formatAge(timestamp) { const mins = Math.round((Date.now() - timestamp) / 60000); if (mins < 1) return 'gerade eben'; if (mins === 1) return 'vor 1 Minute'; if (mins < 60) return `vor ${mins} Minuten`; const hours = Math.round(mins / 60); return hours === 1 ? 'vor 1 Stunde' : `vor ${hours} Stunden`; }