Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-07-10 12:16:47 +02:00
1 parent 97a156ce8f
commit 48466999c0
11 files changed
+307 -18

No files matched your search

+1 -1
View File
@@ -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
+102 -1
View File
@@ -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 = '<div class="loading">⏳ Suche Verbindungen...</div>';
@@ -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' })}
</div>
<div class="journey-duration">${durationText}</div>
<div class="journey-duration">
${journey.fare ? `<span class="journey-fare">🎫 ${journey.fare.amount.toFixed(2).replace('.', ',')} ${journey.fare.currency === 'EUR' ? '€' : escapeHtml(journey.fare.currency)}</span>` : ''}
${durationText}
<button class="reminder-btn" data-dep="${departure.toISOString()}"
data-label="${escapeHtml((journey.legs.find(l => l.line)?.line?.name || 'Verbindung') + ' um ' + departure.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }))}"
aria-label="10 Minuten vor Abfahrt erinnern"
title="Erinnerung 10 min vor Abfahrt (nur solange die App offen ist)">🔔</button>
</div>
</div>
<div class="journey-legs">
${legsHtml}
+15 -5
View File
@@ -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();
+54 -2
View File
@@ -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)