From 48466999c0eaa89a6553d25238d90ef1800ab217 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:16:47 +0200 Subject: [PATCH] Add files via upload --- CHANGELOG.md | 15 ++++++ index.html | 41 ++++++++++++++--- js/api.js | 2 +- js/app.js | 103 +++++++++++++++++++++++++++++++++++++++++- js/extras.js | 20 ++++++-- js/transitous.js | 56 ++++++++++++++++++++++- package.json | 2 +- service-worker.js | 2 +- styles.css | 48 ++++++++++++++++++++ tests/adapter.test.js | 30 ++++++++++++ tests/smoke.test.js | 6 +++ 11 files changed, 307 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7efd6a..568180e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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.5.0] - 2026-07-08 + +### Klickbare Kacheln + die restlichen Feature-Ideen + +#### Added - Routenplaner +- **Via-Station**: Optionales Feld "Über" mit Autocomplete - Verbindungen laufen dann über diese Zwischenstation (API akzeptiert nur Stop-IDs, der Adapter löst Namen automatisch auf). +- **Barrierefrei-Modus** (♿): nutzt das Rollstuhl-Profil für Fußwege und Umstiege. +- **Fahrrad-Mitnahme** (🚲): nur Verbindungen, in denen alle Fahrten Radmitnahme erlauben. +- **Ticketpreise (experimentell)**: withFares ist aktiviert; liefert der Feed Preisdaten, erscheint ein grünes Preis-Badge an der Verbindung. Defensiv gebaut - ohne Daten erscheint schlicht nichts. (Die MOTIS-Spec markiert das Feature selbst als experimentell.) +- **Losgeh-Erinnerung** (🔔): Button an jeder Verbindung, Notification 10 min vor Abfahrt. Ehrliche Einschränkung: funktioniert nur, solange die App/der Tab offen ist - echte Push-Benachrichtigungen bräuchten einen Server. + +#### Changed +- **Alle vier Home-Kacheln sind jetzt klickbar** und öffnen ihre View (vorher nur die Entwickler-Kachel); einheitlich über data-view gelöst, der Spezial-Handler der Dev-Kachel ist raus. +- **API-Budget-Anzeige im Entwickler-Tab**: zeigt live "X/90 Requests frei (letzte 60s)". + ## [37.4.0] - 2026-07-08 ### Favoriten-Routen, Standort auf der Karte, smarterer Geocode diff --git a/index.html b/index.html index 301ad17..495e6bc 100644 --- a/index.html +++ b/index.html @@ -111,28 +111,31 @@
-
+
-
+ +
-
+ +
-
+ +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+
@@ -349,6 +375,7 @@ +
Version:
Release:
API-Budget:–
Lizenz:MIT
Status:✅ Stabil
diff --git a/js/api.js b/js/api.js index d9587c0..4d4740a 100644 --- a/js/api.js +++ b/js/api.js @@ -7,7 +7,7 @@ // 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.4.0'; + const APP_VERSION = '37.5.0'; const APP_RELEASE_DATE = '07.07.2026'; // Seit v35: Transitous (api.transitous.org) ist die einzige diff --git a/js/app.js b/js/app.js index 0171dd0..2bef232 100644 --- a/js/app.js +++ b/js/app.js @@ -1097,6 +1097,8 @@ // Journey Planner State let journeyFromStation = null; let journeyToStation = null; + let journeyViaStation = null; // optionale Zwischenstation + const journeyOptions = { wheelchair: false, bike: false }; // Journey Autocomplete let journeyFromTimeout = null; @@ -1174,6 +1176,38 @@ checkJourneySearchReady(); }); + // Via-Station (optional - beeinflusst den Such-Button nicht) + const journeyVia = document.getElementById('journeyVia'); + const suggestionsVia = document.getElementById('suggestionsVia'); + const clearViaBtn = document.getElementById('clearViaBtn'); + if (journeyVia && suggestionsVia) { + setupJourneyAutocomplete(journeyVia, suggestionsVia, (id, name, lat, lon) => { + journeyViaStation = { id, name, lat, lon }; + }); + journeyVia.addEventListener('input', () => { + // Freitext ohne Auswahl gilt nicht als Via + if (journeyVia.value === '') journeyViaStation = null; + }); + if (clearViaBtn) { + clearViaBtn.addEventListener('click', () => { + journeyVia.value = ''; + journeyViaStation = null; + suggestionsVia.classList.remove('active'); + }); + } + } + + // Options-Chips (Barrierefrei / Fahrrad) togglen + document.addEventListener('click', (e) => { + const chip = e.target.closest('.journey-option-chip'); + if (!chip) return; + const opt = chip.dataset.option; + journeyOptions[opt] = !journeyOptions[opt]; + chip.classList.toggle('active', journeyOptions[opt]); + chip.setAttribute('aria-pressed', String(journeyOptions[opt])); + if (navigator.vibrate) navigator.vibrate(5); + }); + // Clear buttons clearFromBtn.addEventListener('click', () => { journeyFrom.value = ''; @@ -1279,6 +1313,56 @@ // Cursor der letzten Suche (für "Früher"/"Später"-Blättern) let journeyPageCursors = { prev: null, next: null }; + // Losgeh-Erinnerung: Notification 10 min vor Abfahrt. + // EHRLICHE EINSCHRÄNKUNG: funktioniert nur, solange die App/der Tab + // offen ist (echte Push-Benachrichtigungen bräuchten einen Server). + const REMINDER_LEAD_MIN = 10; + + async function scheduleReminder(btn) { + const depTime = new Date(btn.dataset.dep); + const fireAt = depTime.getTime() - REMINDER_LEAD_MIN * 60000; + const delay = fireAt - Date.now(); + + if (delay <= 0) { + btn.textContent = '⏰ zu knapp'; + setTimeout(() => { btn.textContent = '🔔'; }, 2500); + return; + } + + if (!('Notification' in window)) { + btn.textContent = '🚫'; + btn.title = 'Benachrichtigungen werden von diesem Browser nicht unterstützt'; + return; + } + + let permission = Notification.permission; + if (permission === 'default') { + permission = await Notification.requestPermission(); + } + if (permission !== 'granted') { + btn.textContent = '🚫'; + btn.title = 'Benachrichtigungen sind blockiert (Browser-Einstellungen)'; + return; + } + + const label = btn.dataset.label; + setTimeout(() => { + try { + new Notification('🚇 Zeit loszugehen!', { + body: `${label} – Abfahrt in ${REMINDER_LEAD_MIN} Minuten`, + tag: 'vbb-reminder-' + btn.dataset.dep + }); + if (navigator.vibrate) navigator.vibrate([100, 50, 100]); + } catch (e) { /* Tab evtl. eingefroren */ } + }, delay); + + const fireTime = new Date(fireAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + btn.textContent = `🔔 ${fireTime} ✓`; + btn.classList.add('active'); + btn.title = `Erinnerung um ${fireTime} gesetzt (solange die App offen bleibt)`; + if (navigator.vibrate) navigator.vibrate(10); + } + async function runJourneySearch(pageCursor = null) { journeyContainer.innerHTML = '
⏳ Suche Verbindungen...
'; @@ -1290,6 +1374,9 @@ timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null, arriveBy: tMode ? tMode.value === 'arrival' : false, products: activeTransportModes, + via: journeyViaStation, + wheelchair: journeyOptions.wheelchair, + bike: journeyOptions.bike, pageCursor }); @@ -1333,6 +1420,13 @@ return; } + const remBtn = e.target.closest('.reminder-btn'); + if (remBtn) { + e.stopPropagation(); // Karte darunter nicht öffnen + scheduleReminder(remBtn); + return; + } + const pagerBtn = e.target.closest('.journey-pager-btn'); if (pagerBtn && pagerBtn.dataset.cursor) { if (navigator.vibrate) navigator.vibrate(10); @@ -1531,7 +1625,14 @@ → ${arrival.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
-
${durationText}
+
+ ${journey.fare ? `🎫 ${journey.fare.amount.toFixed(2).replace('.', ',')} ${journey.fare.currency === 'EUR' ? '€' : escapeHtml(journey.fare.currency)}` : ''} + ${durationText} + +
${legsHtml} diff --git a/js/extras.js b/js/extras.js index 6b96deb..284d714 100644 --- a/js/extras.js +++ b/js/extras.js @@ -419,11 +419,21 @@ document.addEventListener('DOMContentLoaded', () => { 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')); - } + // Alle Home-Kacheln navigieren per data-view (einheitlich, Delegation) + document.addEventListener('click', (e) => { + const tile = e.target.closest('.home-info-card-btn[data-view]'); + if (tile) { + if (navigator.vibrate) navigator.vibrate(10); + switchView(tile.dataset.view); + } + }); + + // API-Budget-Anzeige im Entwickler-Tab (aktualisiert nur bei offener View) + setInterval(() => { + if (currentView !== 'developer') return; + const el = document.getElementById('devApiBudget'); + if (el) el.textContent = `${apiBudgetLeft()}/${API_SOFT_LIMIT} Requests frei (letzte 60s)`; + }, 2000); // Alte Cache-Einträge aufräumen + letzte Position wiederherstellen cleanupStorage(); diff --git a/js/transitous.js b/js/transitous.js index 935ae55..1d6f0a5 100644 --- a/js/transitous.js +++ b/js/transitous.js @@ -184,6 +184,16 @@ async function transitousDepartures(station, n = 20) { // ROUTENSUCHE (plan -> HAFAS-journeys-Form) // ========================================== +// Name -> Stop-ID auflösen (z.B. für via, das keine Koordinaten erlaubt) +async function transitousResolveStopId(name) { + if (!name) return null; + const matches = await transitousFetch( + `/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=${geocodeBias()}&numResults=1`, + 6 * 60 * 60 * 1000 + ); + return (matches && matches[0]) ? matches[0].id : null; +} + // fromPlace/toPlace: MOTIS akzeptiert "lat,lon" ODER Stop-ID async function transitousPlace(station) { if (station.id && station.id.startsWith(TRANSITOUS_ID_PREFIX)) { @@ -200,22 +210,40 @@ async function transitousPlace(station) { return matches[0].id; } -async function transitousJourneys(fromStation, toStation, { timeISO = null, arriveBy = false, products = null, pageCursor = null } = {}) { +async function transitousJourneys(fromStation, toStation, { + timeISO = null, arriveBy = false, products = null, pageCursor = null, + via = null, wheelchair = false, bike = false +} = {}) { const fromPlace = await transitousPlace(fromStation); const toPlace = await transitousPlace(toStation); let url = `/v6/plan?fromPlace=${encodeURIComponent(fromPlace)}` + `&toPlace=${encodeURIComponent(toPlace)}` + - `&maxTransfers=3&minTransferTime=5`; + `&maxTransfers=3&minTransferTime=5` + + `&withFares=true`; // Experimentell: Preise, falls der Feed sie liefert if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`; if (products) url += `&transitModes=${productsToMotisModes(products)}`; // Blättern: Cursor aus der vorherigen Antwort ("Früher"/"Später") if (pageCursor) url += `&pageCursor=${encodeURIComponent(pageCursor)}`; + // Via-Station: API akzeptiert NUR Stop-IDs (keine Koordinaten) + if (via) { + const viaId = via.id && via.id.startsWith(TRANSITOUS_ID_PREFIX) + ? via.id.slice(TRANSITOUS_ID_PREFIX.length) + : await transitousResolveStopId(via.name); + if (viaId) url += `&via=${encodeURIComponent(viaId)}`; + } + + // Barrierefrei: Rollstuhl-Profil für Fußwege/Umstiege + if (wheelchair) url += `&pedestrianProfile=WHEELCHAIR`; + // Fahrrad-Mitnahme in allen genutzten Fahrten erforderlich + if (bike) url += `&requireBikeTransport=true`; + // 30s Cache: identische Suche direkt hintereinander = 1 Request const data = await transitousFetch(url, 30000); const journeys = (data.itineraries || []).map(it => ({ + fare: extractFare(it), // null, wenn der Feed keine Preisdaten liefert legs: (it.legs || []).map(leg => { const isWalk = leg.mode === 'WALK' || leg.mode === 'BIKE'; return { @@ -252,6 +280,30 @@ async function transitousJourneys(fromStation, toStation, { timeISO = null, arri }; } +// Preis aus der experimentellen Fare-Struktur ziehen - bewusst defensiv: +// liefert null bei fehlenden/unerwarteten Daten, dann zeigt die UI nichts. +function extractFare(itinerary) { + try { + let total = 0, currency = null, found = false; + for (const ft of (itinerary.fareTransfers || [])) { + // Erste Produkt-Alternative pro Transfer nehmen + const alternative = (ft.effectiveFareLegProducts || [])[0]; + if (!alternative) continue; + for (const item of alternative) { + const product = item && (item.product || item); + if (product && typeof product.amount === 'number' && product.amount > 0) { + total += product.amount; + currency = product.currency || currency; + found = true; + } + } + } + return found ? { amount: total, currency: currency || 'EUR' } : null; + } catch (e) { + return null; + } +} + // ========================================== // STATIONEN IN DER NÄHE (map/stops -> HAFAS-nearby-Form) diff --git a/package.json b/package.json index 79db214..46e41b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vbb-netz-status", - "version": "37.4.0", + "version": "37.5.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": { diff --git a/service-worker.js b/service-worker.js index 56fb58c..a0e0d13 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'vbb-status-v37.4.0'; +const CACHE_NAME = 'vbb-status-v37.5.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 44df37e..806c7f8 100644 --- a/styles.css +++ b/styles.css @@ -4210,3 +4210,51 @@ select:focus-visible { .locate-btn { font-size: 15px; } + +/* v37.5: Preis-Badge, Erinnerung, Via/Optionen */ +.journey-fare { + background: rgba(76, 175, 80, 0.15); + border: 1px solid rgba(76, 175, 80, 0.5); + color: #7ED321; + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; + margin-right: 8px; +} + +.reminder-btn { + background: none; + border: 1px solid rgba(255, 237, 0, 0.35); + border-radius: 6px; + color: #FFED00; + padding: 3px 8px; + margin-left: 8px; + font-size: 13px; + cursor: pointer; + font-family: var(--font-stack); +} + +.reminder-btn:hover, +.reminder-btn:focus-visible { + background: rgba(255, 237, 0, 0.12); + outline: none; +} + +.reminder-btn.active { + background: rgba(255, 237, 0, 0.2); + border-style: solid; + font-weight: bold; +} + +.journey-via-row, +.journey-options-row { + margin: 12px 0; +} + +.journey-option-chip[aria-pressed="true"], +.journey-option-chip.active { + background-color: #FFED00; + color: #000; + border-color: #FFED00; + font-weight: bold; +} diff --git a/tests/adapter.test.js b/tests/adapter.test.js index 846fba3..8eb15ae 100644 --- a/tests/adapter.test.js +++ b/tests/adapter.test.js @@ -19,6 +19,7 @@ const fixtures = { agencyName: 'BVG', cancelled: false, tripCancelled: false }], place: {}, previousPageCursor: '', nextPageCursor: '' }, plan: { previousPageCursor: 'CURSOR_PREV', nextPageCursor: 'CURSOR_NEXT', itineraries: [{ + fareTransfers: [{ effectiveFareLegProducts: [[ { product: { name: 'Einzelfahrt AB', amount: 3.80, currency: 'EUR' } } ]] }], 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 }, @@ -212,6 +213,35 @@ const A = global.__a; await A.transitousLocations('teststr2'); t('Bias nutzt letzte GPS-Position', requestedUrls[0].includes('place=52.401,13.052')); + // 14) Routing-Optionen + Via + Preis + A.apiCache.clear(); + requestedUrls.length = 0; + const jOpt = await A.transitousJourneys( + { id: 'x', name: 'Start', lat: 52.47, lon: 13.36 }, + { id: 'y', name: 'Ziel', lat: 52.52, lon: 13.41 }, + { wheelchair: true, bike: true, via: { id: 'transitous:VIA_STOP_1', name: 'Via' } } + ); + const optUrl = requestedUrls.find(u => u.includes('/plan')); + t('Barrierefrei in URL', optUrl.includes('pedestrianProfile=WHEELCHAIR')); + t('Fahrrad in URL', optUrl.includes('requireBikeTransport=true')); + t('Via-Stop-ID in URL (ohne Präfix)', optUrl.includes('via=VIA_STOP_1')); + t('withFares immer an', optUrl.includes('withFares=true')); + t('Preis defensiv extrahiert (3,80 EUR)', + jOpt.journeys[0].fare && jOpt.journeys[0].fare.amount === 3.8 && jOpt.journeys[0].fare.currency === 'EUR'); + + // 15) Fehlende Fare-Daten -> fare ist null (keine UI-Anzeige) + A.apiCache.clear(); + const noFare = JSON.parse(JSON.stringify(fixtures.plan)); + delete noFare.itineraries[0].fareTransfers; + const origPlan = fixtures.plan; + fixtures.plan = noFare; + const jNoFare = await A.transitousJourneys( + { id: 'x2', name: 'S2', lat: 52.4, lon: 13.3 }, + { id: 'y2', name: 'Z2', lat: 52.5, lon: 13.4 }, {} + ); + fixtures.plan = origPlan; + t('Ohne Fare-Daten: fare === null', jNoFare.journeys[0].fare === 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 index 367ad85..e1c5da9 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -271,6 +271,12 @@ setTimeout(() => { else if (!lastJourneyUrl) errors.push('Routen-Chip startet keine Suche'); else console.log('✅ Routen-Chip: 1 Tap füllt Felder und sucht sofort'); + // 15) Home-Kacheln navigieren per Klick + doc.querySelector('.home-info-card-btn[data-view="livemap"]').click(); + await new Promise(r => setTimeout(r, 150)); + if (!doc.getElementById('view-livemap').classList.contains('active')) errors.push('Kachel-Navigation kaputt'); + else console.log('✅ Home-Kacheln navigieren (Live-Map-Kachel geklickt)'); + console.log('✅ Struktur-Checks abgeschlossen'); if (errors.length === 0) {