const i18n = { de: { // Tabs tabMap: 'Karte', tabHistory: 'Verlauf', tabSettings: 'Einstellungen', // Map myLocation: 'Mein Standort', loadingStations: 'Tankstellen laden…', noOpenStations: 'Keine offenen Tankstellen gefunden', showAll: 'Alle anzeigen', noStationsYet: 'Keine Tankstellen in der Nähe gefunden', errorLoading: '❌ Fehler beim Laden', pricesStale: 'Preise evtl. veraltet (API nicht erreichbar)', pricesStaleConnection: 'Preise evtl. veraltet (Verbindungsfehler)', pricesFallback: 'Keine gescannten Tankstellen in der Nähe – Ergebnisse von einer entfernten Messstelle', // Station sheet open: 'Geöffnet', closed: 'Geschlossen', closesAt: 'schließt um', opensAt: 'öffnet um', open24h: 'Rund um die Uhr', openingHours: 'Öffnungszeiten', kmAway: 'km entfernt', dayAbbr: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], // History timePeriod: 'ZEITRAUM', countryDE: 'Deutschland', countryAT: 'Österreich', days7: '7 Tage', days14: '14 Tage', days30: '30 Tage', all: 'Alles', tapForHours: 'Antippen für Stundenansicht', summary: 'ZUSAMMENFASSUNG', lowestPrice: 'Niedrigster Preis', highestPrice: 'Höchster Preis', unknown: 'Unbekannt', currentAvg: 'Aktuell Ø', vsLastWeek: 'vs. letzte Woche', vsLastMonth: 'vs. letzter Monat', // Stats noStats: 'Keine Statistiken verfügbar', avgPrice: 'Durchschnittspreis', lowest: 'Minimum', highest: 'Maximum', measurements: 'Messungen', bestTimes: 'BESTE TANKZEITEN', cheapestDay: 'Günstigster Wochentag', cheapestHour: 'Günstigste Uhrzeit', weekdays: 'WOCHENTAGE', hourRanking: 'UHRZEITEN', stationRanking: 'TANKSTELLEN RANKING', priceSpread: 'PREISSPANNE', vsWorst: 'gespart', periodLastDays: 'Letzte {n} Tage', periodSince: 'Seit {date}', periodToday: 'Heute', locationAutoPicked: 'Automatisch · nächster Standort', historyTooltipHint: 'Antippen für Stundenansicht', historyMinLabel: 'Min', historyAvgLabel: 'Ø', historyHighLabel: 'Max', historyHourTitle: 'Stundenansicht', closeHourView: 'Schließen', oclock: 'Uhr', dayNames: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'], // Settings fuelType: 'KRAFTSTOFF', location: 'STANDORT', priceAlert: 'PREISALARM', notification: 'Benachrichtigung', alertBackgroundHint: 'Läuft im Hintergrund, auch wenn die App geschlossen ist.', alertStatusInactive: 'Noch nicht aktiv', alertStatusArmed: 'Aktiv · überwacht im Hintergrund', alertStatusBelow: 'Aktuell unter Schwellenwert', alertStatusLastFiredFmt: 'Zuletzt benachrichtigt {when} bei {price}', alertStatusCheapestFmt: 'Aktuell günstigster Preis: {price}', alertJustNow: 'gerade eben', alertMinutesAgoFmt: 'vor {n} Min.', alertHoursAgoFmt: 'vor {n} Std.', alertDaysAgoFmt: 'vor {n} Tagen', notificationChannel: 'Benachrichtigungskanal', ntfyTopicPlaceholder: 'ntfy Topic (z.B. mein-tankalarm)', ntfyHint: 'Installiere die ntfy App und abonniere dein Topic.', threshold: 'Schwellenwert', saveAlarm: 'Alarm speichern', sendTestNotification: 'Test-Benachrichtigung senden', appearance: 'DARSTELLUNG', general: 'Allgemein', appearanceLabel: 'Darstellung', themeLight: 'Hell', themeDark: 'Dunkel', language: 'SPRACHE', languageLabel: 'Sprache', about: 'ÜBER', versionLabel: 'Version', viewOnGithub: 'Auf GitHub ansehen', contributors: 'Mitwirkende', ownerRole: 'Ersteller & Maintainer', syncedSetting: 'Wird zwischen Geräten synchronisiert', cloudSyncHint: 'Anmelden für Cloud-Sync, um Einstellungen geräteübergreifend zu synchronisieren', madeWith: 'Gemacht mit', madeIn: 'in Deutschland', currentCheapest: 'Aktuell günstigster Preis', // Account notLoggedIn: 'Nicht eingeloggt', loginSubline: 'Login optional, zum Sync deiner Einstellungen.', loggedIn: 'Eingeloggt', connectedWith: 'Verbunden mit', loginWith: 'Login mit', configureOidc: 'Login ist auf diesem Server nicht eingerichtet.', loggedOut: 'Abgemeldet', // Alerts alertActive: 'Alarm aktiv: Benachrichtigung unter', saved: 'Gespeichert', alertSetMsg: 'Alarm gesetzt. Ich piekse dich bei', under: 'unter', alertFailed: 'Alarm fehlgeschlagen', serverError: 'Server hatte keine Lust.', deleteFailed: 'Alarm löschen fehlgeschlagen', deleteError: 'Nope.', enterNtfyTopic: 'Bitte ein ntfy Topic eingeben.', enterEmail: 'Bitte eine E-Mail-Adresse eingeben.', emailPlaceholder: 'E-Mail-Adresse', emailHint: 'Preisalarme werden an diese Adresse gesendet.', testAlertTitle: 'Tanken - Testalarm!', testAlertBody: 'Das ist eine Test-Benachrichtigung.', testSentNtfy: 'Test an ntfy gesendet!', testSentEmail: 'Test-E-Mail gesendet!', testFailed: 'Test fehlgeschlagen', ntfySendFailed: 'ntfy-Versand fehlgeschlagen.', emailSendFailed: 'E-Mail-Versand fehlgeschlagen.', priceAlertTitle: 'Tanken - Preisalarm!', // Location locationTitle: 'Standort', locationError: 'Konnte Standort nicht ermitteln. Bitte Berechtigung erlauben.', searchPlaceholder: 'Tankstelle oder Ort suchen…', locationBanner: 'Standort konnte nicht ermittelt werden. Bitte suche einen Ort.', noSearchResults: 'Keine Ergebnisse', // PWA pwaTitle: 'Tanken installieren', pwaDesc: 'Füge Tanken zu deinem Startbildschirm hinzu für schnelleren Zugriff und ein App-ähnliches Erlebnis.', pwaAndroid1: 'Tippe auf das Dreipunkt-Menü oben rechts', pwaAndroid2: 'Wähle „App installieren" oder „Zum Startbildschirm hinzufügen"', pwaAndroid3: 'Bestätige mit „Installieren"', pwaIos1: 'Tippe auf das Teilen-Symbol in der Safari-Leiste', pwaIos2: 'Scrolle und wähle „Zum Home-Bildschirm"', pwaIos3: 'Tippe oben rechts auf „Hinzufügen"', pwaWin1: 'Klicke auf das Installations-Symbol in der Adressleiste', pwaWin2: 'Bestätige mit „Installieren"', pwaWin3: 'Optional: An Taskleiste anheften für schnellen Zugriff', // Page headers historyTitle: 'Preisverlauf', historyDescription: 'Wie sich die Preise zuletzt entwickelt haben.', statsTitle: 'Statistiken', statsDescription: 'Wann und wo Tanken am günstigsten ist.', settingsTitle: 'Einstellungen', settingsDescription: 'Deine App-Einstellungen und dein Konto.', feloId: 'FELO ID', aboutTagline: 'Spritpreise im Blick.', // Station history priceHistory: 'PREISVERLAUF', areaHistory: 'Gebietspreisverlauf', noHistory: 'Kein Verlauf verfügbar', sheet24h: '24h', sheet7d: '7 Tage', // Station sort sortPrice: 'Preis', sortDistance: 'Entfernung', stationsFound: 'Tankstellen', alongRoute: 'entlang der Route', addFavourite: 'Zu Favoriten hinzufügen', removeFavourite: 'Aus Favoriten entfernen', maxFavourites: 'Maximale Anzahl an Favoriten erreicht', loginRequiredFavourite: 'Bitte einloggen, um Favoriten zu speichern', favouriteFailed: 'Favorit konnte nicht gespeichert werden', syncFailed: 'Synchronisierung fehlgeschlagen – Einstellung nur lokal gespeichert', sheetExpand: 'Vergrößern', sheetCollapse: 'Verkleinern', retry: 'Erneut versuchen', appUpdated: 'App wurde auf die neueste Version aktualisiert', favouritesOnlyTitle: 'Nur Favoriten anzeigen', noFavouritesHere: 'Keine Favoriten in diesem Gebiet', clearSearch: 'Suche löschen', closeBanner: 'Banner schließen', lastUpdated: 'Zuletzt aktualisiert', minutesAgoFmt: 'vor {n} Min.', hoursAgoFmt: 'vor {n} Std.', justNow: 'gerade eben', unknownLocation: 'Unbekannter Standort', // Scan-Standorte scanLocations: 'SCAN-STANDORTE', historyLocations: 'STANDORTE MIT VERLAUFSDATEN', historyLocationsHint: 'Für diese Standorte sammeln wir täglich Preise für Verlaufscharts. Den aktuellen Preis kannst du jederzeit für jeden beliebigen Ort auf der Karte abrufen.', historyLocationsEmpty: 'Noch keine Standorte mit Verlaufsdaten.', historyLocSummary: 'Preise werden an {n} Standorten gesammelt – {parts}.', historyLocRadius: '{n} km Umkreis', historyLocToggleAria: 'Standortliste ein-/ausklappen', historyLocTotalLabel: 'Standorte', historyLocShowOnMap: 'Auf Karte anzeigen', historyLocOpenOnMap: 'Auf der Karte öffnen', historyLocClose: 'Schließen', shortcuts: 'TASTATURKÜRZEL', shortcutsHint: 'Tippe auf ein Kürzel und drücke die gewünschte Tastenkombination. Backspace entfernt es. Gilt nur auf diesem Gerät.', shortcutsReset: 'Auf Standard zurücksetzen', shortcutRecording: 'Taste drücken…', shortcutNone: 'Keine', scFocusSearch: 'Suche fokussieren', scMyLocation: 'Zu meinem Standort', scSearchHere: 'Hier suchen', scToggleTheme: 'Design wechseln', themeLabel: 'Design', themeAuto: 'Auto', myRequests: 'MEINE ANFRAGEN', requestLocation: 'Standort anfragen', requestsLoginHint: 'Mit FeloID anmelden, um neue Scan-Standorte anzufragen.', requestsOidcOnly: 'Standort-Anfragen nur mit FeloID-Anmeldung möglich.', requestsEmpty: 'Noch keine Anfragen. Neuen Standort über den Button unten anfragen.', requestPending: 'Ausstehend', requestApproved: 'Genehmigt', requestDenied: 'Abgelehnt', requestSent: 'Anfrage gesendet', requestSheetTitle: 'Neuen Scan-Standort anfragen', requestName: 'Name', requestNamePlaceholder: 'z.B. Mein Heimatort', requestAddress: 'Adresse oder Ort', requestAddressPlaceholder: 'Adresse oder Ort suchen…', requestWhy: 'Begründung (optional)', requestWhyPlaceholder: 'Warum sollte hier gescannt werden?', requestSubmit: 'Anfrage senden', requestCancel: 'Abbrechen', requestSending: 'Wird gesendet…', requestFailed: 'Anfrage fehlgeschlagen', requestNameRequired: 'Bitte einen Namen eingeben.', requestLocationRequired: 'Bitte einen Ort auf der Karte wählen.', requestTooMany: 'Zu viele offene Anfragen. Warte bitte auf Bearbeitung.', requestDeniedReason: 'Begründung', requestSearchNoResults: 'Keine Treffer', searchHere: 'Hier suchen', searchingHere: 'Suche läuft…', scanConfirm: 'Scannen', scanCancel: 'Abbrechen', noStationsHere: 'Keine Tankstellen in der Nähe gefunden.', stationNotScanned: 'Diese Tankstelle wird aktuell nicht regelmäßig erfasst.', stationNotScannedHint: 'Du kannst einen Scan-Standort dafür in den Einstellungen anfragen.', requestScanLocation: 'Standort anfragen', historyAccumulating: 'Noch keine Preisverlaufsdaten – sammelt sich mit der Zeit.', noRecent24h: 'Keine Preisdaten in den letzten 24 Stunden.', noRecent7d: 'Keine Preisdaten in den letzten 7 Tagen.', noRecentRange: 'Keine Preisdaten in den letzten {days} Tagen.', manualScanLabel: 'Manuell gescannt', manualScanExpiresIn: 'läuft in', manualScanExpiresSuffix: 'ab', refreshNearby: 'Umgebung neu scannen', historyDefault: 'PREISVERLAUF', historyDefaultLabel: 'Chart-Zeitraum', historyDefault24h: '24 Stunden', historyDefault7d: '7 Tage', alreadyCovered: 'Bereits durch einen Scan-Standort abgedeckt – nicht nötig.', favouritesToggleTitle: 'Favoriten oben anzeigen', groupByPriceTitle: 'Pro Preis nur die nächste Tankstelle', favouritesEmpty: 'Noch keine Favoriten – Tippe auf den Stern bei einer Tankstelle.', favouritesLoginRequired: 'Bitte einloggen, um Favoriten zu sehen.', routePlan: 'Entlang der Route suchen', routeStartPlaceholder: 'Start (aktueller Standort)', routeDestPlaceholder: 'Ziel', routeCurrentLocation: 'Aktueller Standort', routeGo: 'Route finden', cancel: 'Abbrechen', routeLoading: 'Route wird berechnet…', routeLoginRequired: 'Bitte einloggen, um die Routenplanung zu nutzen.', routeNoOrs: 'Routenplanung nicht konfiguriert. Bitte Admin um einen ORS-Key bitten.', routeNoRoute: 'Route konnte nicht berechnet werden.', routeNoStart: 'Bitte einen Startpunkt angeben.', routeNoDest: 'Bitte ein Ziel angeben.', routeNoStations: 'Keine Tankstellen entlang der Route gefunden.', routeExit: 'Route beenden', routeSummary: (km, min, count) => `${km.toFixed(0)} km · ${formatDuration(min)} · ${count} Tankstellen`, }, en: { tabMap: 'Map', tabHistory: 'History', tabSettings: 'Settings', myLocation: 'My Location', loadingStations: 'Loading stations…', noOpenStations: 'No open gas stations found', showAll: 'Show all', noStationsYet: 'No gas stations found nearby', errorLoading: '❌ Error loading', pricesStale: 'Prices may be outdated (API unreachable)', pricesStaleConnection: 'Prices may be outdated (connection error)', open: 'Open', closed: 'Closed', closesAt: 'closes at', opensAt: 'opens at', open24h: 'Open 24 hours', openingHours: 'Opening hours', kmAway: 'km away', dayAbbr: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], timePeriod: 'TIME PERIOD', countryDE: 'Germany', countryAT: 'Austria', days7: '7 Days', days14: '14 Days', days30: '30 Days', all: 'All', tapForHours: 'Tap for hourly detail', summary: 'SUMMARY', lowestPrice: 'Lowest Price', highestPrice: 'Highest Price', unknown: 'Unknown', currentAvg: 'Current Avg', vsLastWeek: 'vs. last week', vsLastMonth: 'vs. last month', noStats: 'No statistics available', avgPrice: 'Average Price', lowest: 'Minimum', highest: 'Maximum', measurements: 'Samples', bestTimes: 'BEST REFUELING TIMES', cheapestDay: 'Cheapest Day', cheapestHour: 'Cheapest Hour', weekdays: 'WEEKDAYS', hourRanking: 'HOURS', stationRanking: 'STATION RANKING', priceSpread: 'PRICE SPREAD', vsWorst: 'saved', periodLastDays: 'Last {n} days', periodSince: 'Since {date}', periodToday: 'Today', locationAutoPicked: 'Auto · nearest location', historyTooltipHint: 'Tap for hourly view', historyMinLabel: 'min', historyAvgLabel: 'avg', historyHighLabel: 'high', historyHourTitle: 'Hourly view', closeHourView: 'Close', oclock: '', dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], fuelType: 'FUEL TYPE', location: 'LOCATION', priceAlert: 'PRICE ALERT', notification: 'Notification', alertBackgroundHint: 'Runs in the background, even when the app is closed.', alertStatusInactive: 'Not active yet', alertStatusArmed: 'Active · monitoring in the background', alertStatusBelow: 'Currently below threshold', alertStatusLastFiredFmt: 'Last notified {when} at {price}', alertStatusCheapestFmt: 'Currently cheapest price: {price}', alertJustNow: 'just now', alertMinutesAgoFmt: '{n} min ago', alertHoursAgoFmt: '{n} h ago', alertDaysAgoFmt: '{n} d ago', notificationChannel: 'Notification Channel', ntfyTopicPlaceholder: 'ntfy Topic (e.g. my-fuel-alert)', ntfyHint: 'Install the ntfy app and subscribe to your topic.', threshold: 'Threshold', saveAlarm: 'Save Alert', sendTestNotification: 'Send Test Notification', appearance: 'APPEARANCE', general: 'General', appearanceLabel: 'Appearance', themeLight: 'Light', themeDark: 'Dark', language: 'LANGUAGE', languageLabel: 'Language', about: 'ABOUT', versionLabel: 'Version', viewOnGithub: 'View on GitHub', contributors: 'Contributors', ownerRole: 'Creator & Maintainer', syncedSetting: 'Synced across devices', cloudSyncHint: 'Sign in for Cloud Sync to sync settings across devices', madeWith: 'Made with', madeIn: 'in Germany', currentCheapest: 'Current cheapest price', notLoggedIn: 'Not logged in', loginSubline: 'Login optional, to sync your settings.', loggedIn: 'Logged in', connectedWith: 'Connected via', loginWith: 'Login with', configureOidc: 'Login is not set up on this server.', loggedOut: 'Logged out', alertActive: 'Alert active: Notification below', saved: 'Saved', alertSetMsg: 'Alert set. I\'ll notify you when', under: 'drops below', alertFailed: 'Alert failed', serverError: 'Server error.', deleteFailed: 'Failed to delete alert', deleteError: 'Nope.', enterNtfyTopic: 'Please enter an ntfy topic.', enterEmail: 'Please enter an email address.', emailPlaceholder: 'Email address', emailHint: 'Price alerts will be sent to this address.', testAlertTitle: 'Tanken - Test Alert!', testAlertBody: 'This is a test notification.', testSentNtfy: 'Test sent to ntfy!', testSentEmail: 'Test email sent!', testFailed: 'Test failed', ntfySendFailed: 'Failed to send via ntfy.', emailSendFailed: 'Failed to send email.', priceAlertTitle: 'Tanken - Price Alert!', locationTitle: 'Location', locationError: 'Could not determine location. Please allow permission.', searchPlaceholder: 'Search station or location…', locationBanner: 'Could not determine location. Please search for a location.', noSearchResults: 'No results', pwaTitle: 'Install Tanken', pwaDesc: 'Add Tanken to your home screen for faster access and an app-like experience.', pwaAndroid1: 'Tap the three-dot menu in the top right', pwaAndroid2: 'Select "Install app" or "Add to Home screen"', pwaAndroid3: 'Confirm with "Install"', pwaIos1: 'Tap the Share icon in the Safari toolbar', pwaIos2: 'Scroll and select "Add to Home Screen"', pwaIos3: 'Tap "Add" in the top right', pwaWin1: 'Click the Install icon in the address bar', pwaWin2: 'Confirm with "Install"', pwaWin3: 'Optional: Pin to taskbar for quick access', historyTitle: 'Price history', historyDescription: 'How prices have moved recently.', statsTitle: 'Statistics', statsDescription: 'When and where fuel is cheapest.', settingsTitle: 'Settings', settingsDescription: 'Your app preferences and account.', feloId: 'FELO ID', aboutTagline: 'Fuel prices at a glance.', priceHistory: 'PRICE HISTORY', areaHistory: 'Area price trend', noHistory: 'No history available', sheet24h: '24h', sheet7d: '7 Days', sortPrice: 'Price', sortDistance: 'Distance', stationsFound: 'Stations', alongRoute: 'along the route', addFavourite: 'Add to favourites', removeFavourite: 'Remove from favourites', maxFavourites: 'Maximum number of favourites reached', loginRequiredFavourite: 'Please log in to save favourites', favouriteFailed: 'Could not save favourite', syncFailed: 'Sync failed – setting saved on this device only', sheetExpand: 'Expand', sheetCollapse: 'Collapse', retry: 'Retry', appUpdated: 'App updated to the latest version', favouritesOnlyTitle: 'Show favourites only', noFavouritesHere: 'No favourites in this area', clearSearch: 'Clear search', closeBanner: 'Dismiss banner', pricesFallback: 'No scanned stations nearby – results from a distant measuring point', lastUpdated: 'Last updated', minutesAgoFmt: '{n} min ago', hoursAgoFmt: '{n} h ago', justNow: 'just now', unknownLocation: 'Unknown location', // Scan locations scanLocations: 'SCAN LOCATIONS', historyLocations: 'LOCATIONS WITH HISTORY DATA', historyLocationsHint: 'We collect daily prices for these locations so you can see trends on the history charts. For the current price you can search any location on the map at any time.', historyLocationsEmpty: 'No locations with history data yet.', historyLocSummary: 'Prices are collected at {n} locations – {parts}.', historyLocRadius: '{n} km radius', historyLocToggleAria: 'Expand/collapse location list', historyLocTotalLabel: 'Locations', historyLocShowOnMap: 'Show on map', historyLocOpenOnMap: 'Open on map', historyLocClose: 'Close', shortcuts: 'KEYBOARD SHORTCUTS', shortcutsHint: 'Tap a shortcut and press the desired key combination. Backspace clears it. Applies on this device only.', shortcutsReset: 'Reset to defaults', shortcutRecording: 'Press a key…', shortcutNone: 'None', scFocusSearch: 'Focus search', scMyLocation: 'Go to my location', scSearchHere: 'Search here', scToggleTheme: 'Toggle theme', themeLabel: 'Theme', themeAuto: 'Auto', myRequests: 'MY REQUESTS', requestLocation: 'Request location', requestsLoginHint: 'Sign in with FeloID to request new scan locations.', requestsOidcOnly: 'Location requests require a FeloID login.', requestsEmpty: 'No requests yet. Tap the button below to request a new location.', requestPending: 'Pending', requestApproved: 'Approved', requestDenied: 'Denied', requestSent: 'Request sent', requestSheetTitle: 'Request a new scan location', requestName: 'Name', requestNamePlaceholder: 'e.g. My hometown', requestAddress: 'Address or place', requestAddressPlaceholder: 'Search address or place…', requestWhy: 'Reason (optional)', requestWhyPlaceholder: 'Why should this area be scanned?', requestSubmit: 'Send request', requestCancel: 'Cancel', requestSending: 'Sending…', requestFailed: 'Request failed', requestNameRequired: 'Please enter a name.', requestLocationRequired: 'Please pick a location on the map.', requestTooMany: 'Too many pending requests. Please wait for review.', requestDeniedReason: 'Reason', requestSearchNoResults: 'No matches', searchHere: 'Search here', searchingHere: 'Searching…', scanConfirm: 'Scan', scanCancel: 'Cancel', noStationsHere: 'No petrol stations nearby.', stationNotScanned: 'This station isn\'t scanned regularly yet.', stationNotScannedHint: 'You can request a scan location for it from the settings.', requestScanLocation: 'Request location', historyAccumulating: 'No price history yet – it builds up over time.', noRecent24h: 'No price data in the last 24 hours.', noRecent7d: 'No price data in the last 7 days.', noRecentRange: 'No price data in the last {days} days.', manualScanLabel: 'Manually scanned', manualScanExpiresIn: 'expires in', manualScanExpiresSuffix: '', refreshNearby: 'Rescan nearby', historyDefault: 'PRICE HISTORY', historyDefaultLabel: 'Chart range', historyDefault24h: '24 hours', historyDefault7d: '7 days', alreadyCovered: 'Already covered by a scan location – no scan needed.', favouritesToggleTitle: 'Pin favourites to top', groupByPriceTitle: 'One station per price (closest)', favouritesEmpty: 'No favourites yet – tap the star on a station.', favouritesLoginRequired: 'Please log in to see your favourites.', routePlan: 'Search along route', routeStartPlaceholder: 'Start (current location)', routeDestPlaceholder: 'Destination', routeCurrentLocation: 'Current location', routeGo: 'Find route', cancel: 'Cancel', routeLoading: 'Calculating route…', routeLoginRequired: 'Please log in to use route planning.', routeNoOrs: 'Route planning not configured. Ask the admin for an ORS key.', routeNoRoute: 'Could not calculate route.', routeNoStart: 'Please enter a start point.', routeNoDest: 'Please enter a destination.', routeNoStations: 'No stations found along the route.', routeExit: 'Exit route', routeSummary: (km, min, count) => `${km.toFixed(0)} km · ${formatDuration(min)} · ${count} stations`, } }; function detectLanguage() { try { const saved = JSON.parse(localStorage.getItem('tank_settings') || '{}').lang; if (saved === 'de' || saved === 'en') return saved; } catch {} const browserLang = (navigator.language || '').slice(0, 2); return browserLang === 'en' ? 'en' : 'de'; } function t(key) { const lang = i18n[state.lang]; if (lang && key in lang) return lang[key]; if (key in i18n.de) return i18n.de[key]; return key; } function isDarkTheme() { const attr = document.documentElement.getAttribute('data-theme'); if (attr === 'dark') return true; if (attr === 'light') return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; } function getTileConfig() { const baseOptions = { maxZoom: 19, attribution: '© OpenStreetMap · CARTO', subdomains: 'abcd' }; if (isDarkTheme()) { return { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', options: baseOptions, className: 'map-tiles-dark', }; } return { url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', options: baseOptions, }; } function refreshMapTiles() { if (!state.map) return; const cfg = getTileConfig(); if (state.tileLayer) { try { state.map.removeLayer(state.tileLayer); } catch {} } state.tileLayer = L.tileLayer(cfg.url, { ...cfg.options, className: cfg.className || '', }).addTo(state.map); refreshCoverageMaskStyle(); } // Translucent gray polygon covering the world with cut-outs for DE and AT, // signalling that those are the only countries the app currently covers. function getCoverageMaskStyle() { const dark = isDarkTheme(); return { color: 'transparent', weight: 0, fill: true, fillColor: dark ? '#000000' : '#1f2937', fillOpacity: dark ? 0.55 : 0.38, interactive: false, }; } function ensureCoverageMask() { if (!state.map || state.coverageMask) return; const outlines = window.COVERAGE_OUTLINES; if (!outlines || !Array.isArray(outlines.de) || !Array.isArray(outlines.at)) return; // Outer ring: a generous world rectangle. Subsequent rings act as holes. // outlines.de / .at are arrays of rings (a country may consist of multiple // polygons such as German offshore islands). const worldRing = [ [-85, -180], [-85, 180], [85, 180], [85, -180], ]; const rings = [worldRing, ...outlines.de, ...outlines.at]; state.coverageMask = L.polygon(rings, getCoverageMaskStyle()).addTo(state.map); } function refreshCoverageMaskStyle() { if (!state.coverageMask) return; try { state.coverageMask.setStyle(getCoverageMaskStyle()); } catch {} } function applyLanguage() { document.documentElement.lang = state.lang; // data-i18n → textContent document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); // data-i18n-html → innerHTML document.querySelectorAll('[data-i18n-html]').forEach(el => { el.innerHTML = t(el.getAttribute('data-i18n-html')); }); // data-i18n-title → title attribute document.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(el.getAttribute('data-i18n-title')); }); // data-i18n-placeholder → placeholder attribute document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); }); // data-i18n-aria-label → aria-label attribute document.querySelectorAll('[data-i18n-aria-label]').forEach(el => { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label'))); }); // ntfy hint (has HTML with link) const ntfyHint = document.getElementById('alert-ntfy-hint'); if (ntfyHint) ntfyHint.innerHTML = t('ntfyHint'); // Update select values const langSelect = document.getElementById('lang-picker'); if (langSelect) langSelect.value = state.lang; // Translate theme option labels document.querySelectorAll('[data-i18n-option]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n-option')); }); // Search-here button is rendered dynamically (cooldown / confirm state), // so keep its label in sync when the language toggles. const searchBtn = document.getElementById('btn-search-here'); if (searchBtn && !searchBtn.disabled) { if (state._scanPicker) setSearchBtnConfirm(searchBtn); else setSearchBtnIdle(searchBtn); } const cancelBtn = document.getElementById('btn-scan-cancel'); if (cancelBtn) cancelBtn.setAttribute('aria-label', t('scanCancel')); // JS-built lists using t() don't carry data-i18n, so refresh them by hand. renderShortcutsList(); } const state = { config: null, stations: [], history: [], stats: null, // Signature (country|location) of the params the currently-rendered // history/stats reflect. Lets a plain tab switch reuse the cached render // instead of re-fetching and replaying the entrance animation. statsKey: null, historyKey: null, fuelType: 'diesel', radiusKm: 25, activeLocation: 'gps', userLat: null, userLng: null, currentTab: 'map', tabOrder: ['map', 'history', 'stats', 'settings'], map: null, markers: [], clusterGroup: null, userMarker: null, chart: null, hourChart: null, sheetChart: null, sheetExpanded: false, stationSort: 'price', historyDays: 7, // Default range for the per-station price chart (1 = 24 h, 7 = 7 days). historyDefaultDays: 7, theme: 'auto', lang: detectLanguage(), activeCountry: null, viewCountry: null, // Manual override for activeCountry. When set ('de' | 'at'), wins over the // GPS-pin-based default. Persisted in localStorage so the user's pick // survives reloads. manualCountry: (() => { try { const v = localStorage.getItem('tank_country'); return v === 'de' || v === 'at' ? v : null; } catch { return null; } })(), manualScans: [], favouritesOnTop: false, favouritesOnly: false, groupByPrice: true, loaded: { map: false, history: false, stats: false, settings: false }, toastTimer: null, me: null, user: null, alertChannel: 'ntfy', alertNtfyTopic: '', alertEmail: '', smtpConfigured: false, alertNotified: false, selectedLocation: '', // True once the user has explicitly changed the location dropdown // (history/stats). While false, the picker auto-defaults to the // scan location nearest to the user's GPS pin. Reset on country // switch so the auto-pick kicks back in for the new country. locationPickerTouched: false, availableLocations: [], priceExtremes: null, // Regional 24h percentile band: { fuel, band: {p10,p50,p90,samples}|null, key }. // `key` is the snapped-anchor cache key so loadPriceBand can short-circuit // when the map only pans within the same cell. Single source of truth for // heatmap colors — when no band is available, stations render in a neutral // gray rather than against a misleading anchor. priceBand: { fuel: null, band: null, key: null }, favourites: [], // ── Route planner ──────────────────────────────────────────────── routeMode: false, // true while a route is being displayed routeLayer: null, // Leaflet polyline routeStart: null, // { lat, lng, label } routeDest: null, // { lat, lng, label } routeStartMarker: null, routeDestMarker: null, routeSummary: null, // { distanceKm, durationMin, count } routeScanMarkers: null, // Leaflet markers at scan points (yellow dots) routeScanRunId: 0, // guard against overlapping scan loops }; const localSettingsKey = 'tank_settings'; const _WebHaptics = typeof WebHapticsModule !== 'undefined' ? WebHapticsModule.WebHaptics : (typeof WebHaptics !== 'undefined' ? WebHaptics : null); const _haptics = _WebHaptics ? new _WebHaptics() : null; const haptic = (type = 'light') => { try { if (_haptics) { _haptics.trigger(type === 'selection' ? [{ duration: 12, intensity: 0.6 }] : type); return; } if (navigator.vibrate) { const patterns = { success: [20, 30, 20], medium: [18], heavy: [25], selection: [10], light: [12], warning: [15, 30, 15], error: [25, 20, 25, 20, 25] }; navigator.vibrate(patterns[type] || [12]); } } catch {} }; // Reusable SVG path data for confetti and decorative glyphs. Keeping them // in one place means a confetti burst exactly mirrors the icon it spawns // from (price tag → tag-shaped pieces, calendar → calendar pieces, …). const ICON_PATHS = { priceTag: '', trendDown: '', trendUp: '', chart: '', calendar: '', clock: '', star: '', fuel: '', heart: '', cloud: '', }; // Fun "confetti" burst. Spawns N small SVG particles at (originX, originY), // each with random initial velocity, rotation, and scale. Particles obey a // crude gravity + drag model and fade out. Cleans itself up after ~1.2s. function spawnConfetti(originX, originY, svgInner, opts = {}) { const count = opts.count ?? 26; const colors = opts.colors ?? ['#34c759', '#007aff', '#ff9500', '#ffcc00', '#af52de', '#ff3b30']; const size = opts.size ?? 18; const fixedColor = opts.fixedColor; const container = document.createElement('div'); container.className = 'confetti-burst'; document.body.appendChild(container); const particles = []; for (let i = 0; i < count; i++) { const el = document.createElement('div'); el.className = 'confetti-piece'; const color = fixedColor || colors[i % colors.length]; if (opts.imgSrc) { el.innerHTML = ``; } else { el.innerHTML = `${svgInner}`; } el.style.left = originX + 'px'; el.style.top = originY + 'px'; container.appendChild(el); const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.3; const speed = 240 + Math.random() * 320; particles.push({ el, x: 0, y: 0, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, rot: Math.random() * 360, rotVel: (Math.random() - 0.5) * 480, scale: 0.65 + Math.random() * 0.55 }); } const start = performance.now(); const dur = 1900; let lastT = start; function frame(now) { const elapsed = now - start; const dt = Math.min(0.05, (now - lastT) / 1000); lastT = now; if (elapsed > dur) { container.remove(); return; } const t = elapsed / dur; const opacity = t < 0.72 ? 1 : 1 - (t - 0.72) / 0.28; for (const p of particles) { p.vy += 800 * dt; p.vx *= 0.985; p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.rotVel * dt; p.el.style.transform = `translate(-50%, -50%) translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg) scale(${p.scale})`; p.el.style.opacity = opacity.toFixed(3); } requestAnimationFrame(frame); } requestAnimationFrame(frame); } // Wire up an element so that clicking it triggers a confetti burst from its // centre, a haptic tap, and a brief scale-pop animation on the element. function attachConfetti(el, svgInner, opts = {}) { if (!el || el._confettiAttached) return; el._confettiAttached = true; el.classList.add('confetti-trigger'); el.addEventListener('click', (e) => { if (opts.stopPropagation !== false) e.stopPropagation(); haptic('light'); const r = el.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, svgInner, opts); el.classList.remove('confetti-pop'); void el.offsetWidth; el.classList.add('confetti-pop'); }); } // Replace the native
`; if (state._sheetDragCleanup) { state._sheetDragCleanup(); state._sheetDragCleanup = null; } sheet.classList.remove('hidden'); sheet.setAttribute('aria-hidden', 'false'); const backdrop = sheet.querySelector('.bottom-sheet-backdrop'); const content = sheet.querySelector('.bottom-sheet-content'); content.style.transform = ''; content.classList.remove('dragging', 'snapping', 'expanded'); state.sheetExpanded = false; const handleArea = document.getElementById('sheet-handle-area'); content.querySelector('.sheet-expand-btn')?.remove(); const expandBtn = document.createElement('button'); expandBtn.className = 'sheet-expand-btn'; updateExpandBtnIcon(expandBtn, false); expandBtn.addEventListener('click', () => toggleSheetExpanded(content)); content.appendChild(expandBtn); const closeSheet = () => { if (reqState.map) { try { reqState.map.remove(); } catch {} reqState.map = null; } state.sheetExpanded = false; content.style.transform = ''; content.classList.remove('dragging', 'snapping', 'expanded'); content.querySelector('.sheet-expand-btn')?.remove(); content.querySelector('.sheet-desktop-close')?.remove(); backdrop.style.opacity = ''; sheet.classList.add('hidden'); sheet.setAttribute('aria-hidden', 'true'); backdrop.removeEventListener('click', closeSheet); if (state._sheetDragCleanup) { state._sheetDragCleanup(); state._sheetDragCleanup = null; } }; backdrop.addEventListener('click', closeSheet); if (window.matchMedia('(min-width: 900px)').matches) { content.querySelector('.sheet-desktop-close')?.remove(); const closeBtn = document.createElement('button'); closeBtn.className = 'sheet-desktop-close'; closeBtn.innerHTML = '×'; closeBtn.addEventListener('click', closeSheet); content.prepend(closeBtn); } setupSheetDrag(content, handleArea, backdrop, closeSheet); document.getElementById('req-cancel').addEventListener('click', closeSheet); // Initialise Leaflet map const mapEl = document.getElementById('req-map'); if (mapEl && window.L) { const L = window.L; const map = L.map(mapEl, { zoomControl: true, attributionControl: false }).setView([reqState.lat, reqState.lng], 11); const reqTileCfg = getTileConfig(); L.tileLayer(reqTileCfg.url, { ...reqTileCfg.options, className: reqTileCfg.className || '', }).addTo(map); const marker = L.marker([reqState.lat, reqState.lng], { draggable: true }).addTo(map); const circle = L.circle([reqState.lat, reqState.lng], { radius: reqState.radiusKm * 1000, color: '#007aff', fillColor: '#007aff', fillOpacity: 0.12, weight: 2, }).addTo(map); reqState.map = map; reqState.marker = marker; reqState.circle = circle; marker.on('dragend', () => { const p = marker.getLatLng(); reqState.lat = p.lat; reqState.lng = p.lng; circle.setLatLng(p); }); map.on('click', (e) => { reqState.lat = e.latlng.lat; reqState.lng = e.latlng.lng; marker.setLatLng(e.latlng); circle.setLatLng(e.latlng); }); setTimeout(() => map.invalidateSize(), 200); } // Address search const searchInput = document.getElementById('req-search'); const searchResults = document.getElementById('req-search-results'); const runSearch = async (q) => { if (q.length < 2) { searchResults.style.display = 'none'; searchResults.innerHTML = ''; return; } try { const res = await api(`/api/geocode?q=${encodeURIComponent(q)}&lang=${encodeURIComponent(state.lang || 'de')}`); const results = Array.isArray(res.results) ? res.results : []; if (!results.length) { searchResults.innerHTML = `
${t('requestSearchNoResults')}
`; searchResults.style.display = ''; return; } searchResults.innerHTML = results.map((r, i) => ` `).join(''); searchResults.style.display = ''; searchResults.querySelectorAll('.req-search-hit').forEach((btn) => { btn.addEventListener('click', () => { const idx = Number(btn.dataset.idx); const hit = results[idx]; if (!hit) return; reqState.lat = Number(hit.lat); reqState.lng = Number(hit.lng); if (reqState.marker) reqState.marker.setLatLng([reqState.lat, reqState.lng]); if (reqState.circle) reqState.circle.setLatLng([reqState.lat, reqState.lng]); if (reqState.map) reqState.map.setView([reqState.lat, reqState.lng], 12); searchInput.value = hit.name; searchResults.style.display = 'none'; }); }); } catch { searchResults.style.display = 'none'; } }; searchInput.addEventListener('input', () => { const q = searchInput.value.trim(); clearTimeout(reqState.searchTimer); reqState.searchTimer = setTimeout(() => runSearch(q), 400); }); // Submit const submitBtn = document.getElementById('req-submit'); const nameInput = document.getElementById('req-name'); const noteInput = document.getElementById('req-note'); if (presetName && nameInput) nameInput.value = presetName; if (presetAddress) { const addressInput = document.getElementById('req-search'); if (addressInput) addressInput.value = presetAddress; } submitBtn.addEventListener('click', async () => { if (reqState.sending) return; const name = nameInput.value.trim(); if (!name) { showToast(t('requestNameRequired')); nameInput.focus(); return; } if (!Number.isFinite(reqState.lat) || !Number.isFinite(reqState.lng)) { showToast(t('requestLocationRequired')); return; } reqState.sending = true; submitBtn.disabled = true; submitBtn.textContent = t('requestSending'); try { await api('/api/location-requests', { method: 'POST', body: JSON.stringify({ name, lat: reqState.lat, lng: reqState.lng, radiusKm: reqState.radiusKm, note: noteInput.value.trim() || undefined, }), }); showToast(t('requestSent')); closeSheet(); renderUserRequests(); } catch (err) { if (err && err.status === 429) showToast(t('requestTooMany')); else showToast((err && err.message) || t('requestFailed')); reqState.sending = false; submitBtn.disabled = false; submitBtn.textContent = t('requestSubmit'); } }); } function browserGeolocation() { if (!navigator.geolocation) { showLocationBanner(); return loadMapTab(); } navigator.geolocation.getCurrentPosition( (pos) => { state.userLat = pos.coords.latitude; state.userLng = pos.coords.longitude; state.activeLocation = 'gps'; document.getElementById('map-location-banner')?.classList.add('hidden'); // If the map is already showing roughly this area (restored last // viewport), refresh the data in place instead of yanking the view. let near = false; if (state.map) { try { const c = state.map.getCenter(); near = distanceKm(c.lat, c.lng, state.userLat, state.userLng) < 2; } catch {} } loadMapTab({ skipFitBounds: near, silent: near }); }, () => { showLocationBanner(); loadMapTab(); }, { timeout: 8000, maximumAge: 60000 } ); } function showLocationBanner() { const banner = document.getElementById('map-location-banner'); if (!banner) return; banner.classList.remove('hidden'); const closeBtn = document.getElementById('map-location-banner-close'); if (closeBtn && !closeBtn._setup) { closeBtn._setup = true; closeBtn.addEventListener('click', () => banner.classList.add('hidden')); } setTimeout(() => banner.classList.add('hidden'), 8000); } // Last map viewport, persisted across sessions so a returning user's map // opens where they left it instead of flashing the Berlin fallback and // jumping once geolocation resolves. const lastViewKey = 'tank_last_view'; let _storeViewTimer = null; function storeMapView() { // Debounced — moveend fires for every gesture, but only the resting // viewport matters, so don't do synchronous localStorage writes per pan. clearTimeout(_storeViewTimer); _storeViewTimer = setTimeout(() => { if (!state.map) return; try { const c = state.map.getCenter(); localStorage.setItem(lastViewKey, JSON.stringify({ lat: Number(c.lat.toFixed(5)), lng: Number(c.lng.toFixed(5)), zoom: state.map.getZoom() })); } catch {} }, 600); } function readStoredMapView() { try { const v = JSON.parse(localStorage.getItem(lastViewKey) || 'null'); if (v && Number.isFinite(v.lat) && Number.isFinite(v.lng)) return v; } catch {} return null; } function getActiveCoords() { if (Number.isFinite(state.userLat) && Number.isFinite(state.userLng)) { return { lat: state.userLat, lng: state.userLng }; } // Prefer the last viewport the user actually looked at. Deliberate side // effect: without GPS and without an explicit country choice // (state.manualCountry), getActiveCountry() now follows this too — the // last-viewed area is a better default than always-Berlin. const stored = readStoredMapView(); if (stored) return { lat: stored.lat, lng: stored.lng }; // Fall back to Berlin (purely for the initial map view — distance // calculations should NOT use this). return { lat: 52.52, lng: 13.405 }; } // The user's actual location (from device GPS), or null when unknown. // Distance labels are computed from this — never from the map centre or // from a search-bar pick, so the "X km entfernt" stays honest. function getUserGpsCoords() { if (Number.isFinite(state.userLat) && Number.isFinite(state.userLng)) { return { lat: state.userLat, lng: state.userLng }; } return null; } // Apply (or strip) the dist field on a list of stations using the user's // real GPS position. When GPS is unknown, dist is removed so the UI shows // no distance instead of a misleading number. function withDistanceFromUser(stations) { if (!Array.isArray(stations)) return []; const gps = getUserGpsCoords(); if (!gps) { return stations.map(s => { // eslint-disable-next-line no-unused-vars const { dist, distApprox, ...rest } = s; return rest; }); } return stations.map(s => ({ ...s, dist: distanceKm(gps.lat, gps.lng, s.lat, s.lng), distApprox: false, })); } // Cheap pre-filter — AT's actual bounding box. Southern Germany (Munich, // Ulm, Augsburg, Lindau) overlaps this box, so we still need a proper // point-in-polygon check for anything inside it. const AT_BBOX = { south: 46.37, north: 49.02, west: 9.53, east: 17.16 }; function pointInRing(lat, lng, ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const yi = ring[i][0], xi = ring[i][1]; const yj = ring[j][0], xj = ring[j][1]; const intersect = ((yi > lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; } // Decides whether a coordinate sits inside Austria. Uses the loaded AT // outline (Natural Earth 10 m) if available; otherwise falls back to the // bounding box so we don't deadlock before outlines arrive. function isInAustria(lat, lng) { if (lat < AT_BBOX.south || lat > AT_BBOX.north || lng < AT_BBOX.west || lng > AT_BBOX.east) { return false; } const outlines = window.COVERAGE_OUTLINES; const rings = outlines && Array.isArray(outlines.at) ? outlines.at : null; if (!rings || !rings.length) { // Outlines haven't loaded yet — be permissive, UI will re-check on next event. return true; } for (const ring of rings) { if (pointInRing(lat, lng, ring)) return true; } return false; } function getActiveCountry() { if (state.manualCountry === 'de' || state.manualCountry === 'at') { return state.manualCountry; } const c = getActiveCoords(); return isInAustria(c.lat, c.lng) ? 'at' : 'de'; } // Sync the active class on the country chips in both the Verlauf and Stats // tabs to match the current activeCountry. Called whenever applyCountryUi // runs and on tab init. function syncCountryChips() { const country = state.activeCountry || getActiveCountry(); document.querySelectorAll('#history-country-chips .chip, #stats-country-chips .chip').forEach(chip => { chip.classList.toggle('active', chip.dataset.country === country); }); } // Attach click handlers to the country chips in a given chip-row. Idempotent: // uses a dataset flag so re-running on the same row (e.g. on tab re-init) // doesn't stack listeners. function wireCountryChips(containerId) { const row = document.getElementById(containerId); if (!row || row.dataset.wired === '1') return; row.dataset.wired = '1'; row.querySelectorAll('.chip[data-country]').forEach(chip => { chip.addEventListener('click', () => { haptic('light'); setManualCountry(chip.dataset.country); }); }); } async function setManualCountry(next) { if (next !== 'de' && next !== 'at') return; if (state.manualCountry === next && state.activeCountry === next) return; state.manualCountry = next; try { localStorage.setItem('tank_country', next); } catch {} applyCountryUi(); // resets selectedLocation + loaded flags when country changes syncCountryChips(); // Reload data for whichever country-scoped tabs the user has already opened. if (state.loaded.history === false) { // applyCountryUi cleared the loaded flag — re-init the whole tab so the // location picker is re-fetched for the new country. if (state.currentTab === 'history') await loadHistoryTab(); } if (state.loaded.stats === false) { if (state.currentTab === 'stats') await loadStatsTab(); } } // "Active" country = where the user's pin is (from GPS or last explicit // search). Drives history/stats filtering — should NOT flip when the user // just pans the map around. function applyCountryUi() { const next = getActiveCountry(); applyViewCountryUi(); // view-country always follows the map center if (state.activeCountry === next) { syncCountryChips(); return; } const prev = state.activeCountry; state.activeCountry = next; document.documentElement.setAttribute('data-active-country', next); // Country change invalidates cached history/stats — they're country-scoped now. if (prev && prev !== next) { state.selectedLocation = ''; state.locationPickerTouched = false; state.loaded.history = false; state.loaded.stats = false; } syncCountryChips(); } // "View" country = whatever the map centre is currently over. Drives the // scan button and other map-chrome UI, because the user's intent is tied // to what they're looking at, not where their pin sits. function applyViewCountryUi() { const center = state.map ? state.map.getCenter() : null; const next = center ? (isInAustria(center.lat, center.lng) ? 'at' : 'de') : getActiveCountry(); if (state.viewCountry === next) return; state.viewCountry = next; document.documentElement.setAttribute('data-view-country', next); // Entering AT view: clear DE-only affordances the user can't use anyway. // The cooldown timer keeps ticking silently — the button is hidden via // CSS in AT mode; if the user returns to DE the countdown is still // accurate (and survives a refresh via localStorage). if (next === 'at') { if (state._scanPicker) exitScanPickerMode(); if (Array.isArray(state._scanQueue)) { state._scanQueue.forEach(item => { try { state.map.removeLayer(item.pin); } catch {} }); state._scanQueue = []; } } else if (next === 'de') { // Coming back to DE: re-arm the cooldown UI from storage if the timer // was never started this session (or was cleared somewhere). if (!state._searchHereCooldownTimer) restoreSearchHereCooldown(); } } function showStationSkeletons(count = 5) { const list = document.getElementById('station-list'); if (!list) return; list.innerHTML = Array.from({ length: count }, () => `
`).join(''); } let _mapTabInflight = null; let _mapTabInflightUrl = ''; async function loadMapTab({ skipFitBounds = false, silent = false } = {}) { state.loaded.map = true; const coords = getActiveCoords(); applyCountryUi(); if (!state.map) { // Restore the last session's zoom when the view comes from storage. const storedView = readStoredMapView(); const initialZoom = (!state.userLat && storedView && Number.isFinite(storedView.zoom)) ? storedView.zoom : 12; state.map = L.map('map', { zoomControl: false, attributionControl: false, doubleClickZoom: true, touchZoom: true, tapHold: false }).setView([coords.lat, coords.lng], initialZoom); refreshMapTiles(); ensureCoverageMask(); setupMapZoomGesture(); setupMapSearch(); setupRoutePanel(); setupSearchHereButton(); // Markers are hidden when zoomed out past the world-view threshold so // we don't draw thousands of pins. On zoom-in we DON'T re-render the // previous state.stations — the centre may have moved while zoomed out // (zoom out → pan → zoom in elsewhere), so the old data would briefly // flash at the wrong coords and the list would still show old entries // until the debounced loader fired. Instead we fire the loader // immediately on zoom so list + markers refresh together. const MIN_ZOOM_FOR_STATIONS = 10; // Cancel any pending viewport refresh the moment a new zoom starts. // Without this, a previous zoomend's deferred timer can fire mid-way // through the next zoom's animation — renderStationsOnMap then tears // down state.clusterGroup and the merge/split animation gets cut off. // Rapid zoom in/out is the worst case: each zoomend sets a timer, and // if a new zoomstart lands within that window the timer must die. state.map.on('zoomstart', () => { if (_viewportTimer) { clearTimeout(_viewportTimer); _viewportTimer = null; } }); state.map.on('zoomend', () => { if (!state.map) return; // Route mode pins the list to the corridor result — don't let zoom // clear the markers or trigger a viewport reload that would clobber // state.stations. Re-render so the corridor pins survive a zoom that // would otherwise drop below MIN_ZOOM_FOR_STATIONS. if (state.routeMode) { renderStationsOnMap(state.stations || [], { skipFitBounds: true, skipRadiusFilter: true }); return; } const zoom = state.map.getZoom(); if (zoom < MIN_ZOOM_FOR_STATIONS) { if (state.clusterGroup) { state.map.removeLayer(state.clusterGroup); state.clusterGroup = null; } state.markers.forEach(m => state.map.removeLayer(m)); state.markers = []; } else if (!state.clusterGroup && (state.stations || []).length) { // Back in station-display range, but the cluster was torn down by an // earlier zoom-out below MIN_ZOOM_FOR_STATIONS. Re-render the cached // stations immediately so the user never sees an empty map while the // debounced network refresh below catches up — and so a rapid zoom // that cancels that refresh can't strand the map empty. renderStationsOnMap(state.stations, { skipFitBounds: true, skipRadiusFilter: true }); } // Refresh on zoom — the visible viewport (and therefore the bounds // query / scan-location selection) changes drastically, and skipping // would leave the user with stale markers / a clipped list. Deferred // ~400 ms so MarkerCluster's own merge/split animation can play out // before renderStationsOnMap tears down state.clusterGroup. A fast // double-zoom resets the timer via _viewportTimer instead of stacking. if (zoom >= VIEWPORT_MIN_ZOOM) { if (_viewportTimer) clearTimeout(_viewportTimer); _viewportTimer = setTimeout(() => { _viewportTimer = null; syncManualScansFromServer({ rerender: false }); loadStationsAroundCenter({ silent: true }); }, 400); } }); state.map.on('moveend', () => { showSearchHereIfMoved(); applyViewCountryUi(); scheduleViewportRefresh(); storeMapView(); }); } // Austria has full grid-cache coverage — pull stations for the current // viewport (25 km circle around the centre) instead of the radius-around-pin // query, so the list and markers follow what the user is looking at. if (getActiveCountry() === 'at') { const loader = document.getElementById('map-loading'); if (!silent) { loader.classList.remove('hidden'); showStationSkeletons(); } await loadStationsAroundCenter({ silent }); return; } const loader = document.getElementById('map-loading'); if (!silent) { loader.classList.remove('hidden'); showStationSkeletons(); } // Cold boot fires loadMapTab twice (initial tab load + geolocation // settling). When both resolve to the same query, share one request // instead of hitting the API twice. const stationsUrl = `/api/stations?lat=${coords.lat}&lng=${coords.lng}&rad=${state.radiusKm}&fuel=${state.fuelType}`; if (_mapTabInflightUrl === stationsUrl && _mapTabInflight) { try { await _mapTabInflight; } catch {} return; } try { _mapTabInflightUrl = stationsUrl; _mapTabInflight = api(stationsUrl); const result = await _mapTabInflight; const rawStations = Array.isArray(result) ? result : []; const cacheStatus = result._cacheStatus || 'fresh'; // Distance labels follow GPS, not the query centre — strips dist if no GPS. const stations = withDistanceFromUser(rawStations); state.stations = stations; state.dataTimestamp = result._dataTimestamp || null; renderStationsOnMap(stations, { skipFitBounds }); renderStationList(stations); state._searchHereAnchor = { lat: coords.lat, lng: coords.lng }; if (!stations.length) { loader.innerHTML = `${t('noStationsYet')}`; } else { loader.classList.add('hidden'); } if (cacheStatus === 'stale') { showToast(t('pricesStale')); } else if (cacheStatus === 'fallback') { showToast(t('pricesFallback')); } checkPriceAlert(stations); } catch (error) { // Show last known stations from state if available if (state.stations?.length) { renderStationsOnMap(state.stations); renderStationList(state.stations); loader.classList.add('hidden'); showToast(t('pricesStaleConnection')); } else { renderMapLoadError(loader, () => loadMapTab({ skipFitBounds })); } } finally { _mapTabInflight = null; _mapTabInflightUrl = ''; } } // Error state on the map overlay with a real retry affordance — without it // the first-load failure is a dead end on desktop (pull-to-refresh is // touch-only and unavailable there). function renderMapLoadError(loader, retry) { loader.classList.remove('hidden'); loader.innerHTML = `${t('errorLoading')}`; loader.querySelector('.map-retry-btn')?.addEventListener('click', () => { haptic('light'); loader.innerHTML = `${t('loadingStations')}`; retry(); }, { once: true }); } // ─── Centre-driven viewport loader ────────────────────────────────── // // As the user pans, the list and markers should follow an invisible // 25 km circle around the map centre. Two strategies depending on // country: // AT — query the cached grid via ?bounds= and trim to the 25 km // circle client-side. // DE — find admin scan locations whose 25 km radius overlaps the // user's 25 km circle (i.e. centres within 50 km), then fetch // each one's full cached set so the user sees the entire scan // radius even when pieces of it sit outside the centre circle. const VIEWPORT_RADIUS_KM = 25; const VIEWPORT_DEBOUNCE_MS = 350; const VIEWPORT_MIN_ZOOM = 8; // Skip the viewport refresh when the map centre has barely moved since // the last successful load — pure zoom (no pan) and tiny adjustments // don't change the result, so re-fetching just thrashes the list. const VIEWPORT_MIN_MOVE_KM = 1.5; let _viewportTimer = null; let _viewportInflight = null; // Sorted "id:price" fingerprint over a station set. Used by // loadStationsAroundCenter to detect a no-op re-render so the // MarkerCluster plugin's own zoom merge/split animation can play out // instead of being destroyed by a fresh cluster build. function stationFingerprint(stations) { if (!Array.isArray(stations) || stations.length === 0) return ''; const parts = new Array(stations.length); for (let i = 0; i < stations.length; i++) { const s = stations[i]; parts[i] = `${s.id || `${s.lat},${s.lng}`}:${s.price || 0}`; } parts.sort(); return parts.join('|'); } function viewportCenterMovedEnough() { const last = state._lastViewportCenter; if (!last) return true; if (!state.map) return false; const c = state.map.getCenter(); return distanceKm(last.lat, last.lng, c.lat, c.lng) >= VIEWPORT_MIN_MOVE_KM; } function scheduleViewportRefresh() { if (!state.map) return; if (state.routeMode) return; // frozen list while route is active if (state.map.getZoom() < VIEWPORT_MIN_ZOOM) return; if (!viewportCenterMovedEnough()) return; if (_viewportTimer) clearTimeout(_viewportTimer); _viewportTimer = setTimeout(() => { _viewportTimer = null; // Run the manual-scan sync alongside the viewport load so panning // into an area a peer just scanned surfaces their markers without // waiting for the next 60 s poll. syncManualScansFromServer({ rerender: false }); loadStationsAroundCenter({ silent: true }); }, VIEWPORT_DEBOUNCE_MS); } async function loadStationsAroundCenter({ silent = true } = {}) { if (!state.map) return; if (state.routeMode) return; // route mode freezes the station list const c = state.map.getCenter(); const lat = c.lat; const lng = c.lng; const isAt = isInAustria(lat, lng); // Refresh the regional price band against the new centre. No-ops when the // snapped anchor matches the last fetch, so pans within a cell don't churn. loadPriceBand(lat, lng); // Coalesce concurrent in-flight requests so the latest move wins. if (_viewportInflight) { try { await _viewportInflight; } catch {} } const loader = document.getElementById('map-loading'); const fetchPromise = (async () => { try { let stations = []; if (isAt) { // Austria: query whichever is larger — the visible viewport or the // guaranteed 25 km circle around the centre. That way the list // always covers at least 25 km (zoomed-in case) AND every station // currently visible on the map gets a marker (zoomed-out case). const latMargin = VIEWPORT_RADIUS_KM / 111; const lngMargin = VIEWPORT_RADIUS_KM / Math.max(0.001, 111 * Math.cos((lat * Math.PI) / 180)); const visible = state.map.getBounds(); const south = Math.min(lat - latMargin, visible.getSouth()).toFixed(5); const north = Math.max(lat + latMargin, visible.getNorth()).toFixed(5); const west = Math.min(lng - lngMargin, visible.getWest()).toFixed(5); const east = Math.max(lng + lngMargin, visible.getEast()).toFixed(5); const result = await api(`/api/stations?bounds=${south},${west},${north},${east}&fuel=${state.fuelType}`); stations = Array.isArray(result) ? result : []; state.dataTimestamp = result?._dataTimestamp || null; // No additional client-side filter — show everything the query // returned so zoomed-out viewports don't end up with empty patches. } else { // Germany: find every DE scan location whose 25 km radius reaches // the user's 25 km circle (i.e. centre-to-centre ≤ 50 km). const scanLocs = await ensureScanLocations(); const matching = (scanLocs || []).filter((loc) => { if (loc.country !== 'de') return false; const r = Number(loc.radiusKm) > 0 ? Number(loc.radiusKm) : 25; return distanceKm(lat, lng, loc.lat, loc.lng) <= VIEWPORT_RADIUS_KM + r; }); if (matching.length) { const results = await Promise.allSettled( matching.map((loc) => api(`/api/stations?location=${encodeURIComponent(loc.id)}&fuel=${state.fuelType}`), ), ); const seen = new Set(); for (let i = 0; i < results.length; i++) { const r = results[i]; if (r.status !== 'fulfilled') continue; const list = Array.isArray(r.value) ? r.value : []; const locId = matching[i].id; for (const s of list) { const k = s.id || `${s.lat},${s.lng}`; if (seen.has(k)) continue; seen.add(k); s._locationId = locId; stations.push(s); } } } } // Distance labels track the user's real GPS position (not the map // centre, not the search-bar pick). When GPS is unknown the dist // field is stripped so no misleading "X km entfernt" appears. stations = withDistanceFromUser(stations); // Guard against a transient empty result wiping the map. If the fetch // came back with zero stations while we still have some and the centre // barely moved (a zoom keeps the centre fixed), it's almost certainly a // hiccup — a timeout or a momentary backend gap — not a genuinely empty // area. Keep the existing markers/list and let the next refresh correct // it. A real pan into empty territory moves the centre and still clears. if (!stations.length && (state.stations || []).length) { const prev = state._lastViewportCenter; const movedKm = prev ? distanceKm(lat, lng, prev.lat, prev.lng) : Infinity; if (movedKm < VIEWPORT_MIN_MOVE_KM) { state._lastViewportCenter = { lat, lng }; if (loader) loader.classList.add('hidden'); return; } } // Skip rebuilding the cluster if the station set is bit-for-bit // identical to what's already on the map. The MarkerCluster plugin // has its own smooth merge/split animation on zoom — destroying and // recreating the cluster on every viewport refresh kills that // animation. Compare by sorted "id:price" fingerprint so price // changes still trigger a real re-render. const newFp = stationFingerprint(stations); const oldFp = stationFingerprint(state.stations); state.stations = stations; state._lastViewportCenter = { lat, lng }; const skipRender = newFp === oldFp && !!state.clusterGroup; // DE shows the full scan-location set even when stations sit beyond // the user's 25 km circle, so skip the per-list radius filter there. const renderOpts = { skipFitBounds: true, skipRadiusFilter: true }; if (!skipRender) { renderStationsOnMap(stations, renderOpts); renderStationList(stations); } if (loader) { if (!stations.length && !silent) { loader.innerHTML = `${t('noStationsYet')}`; } else { loader.classList.add('hidden'); } } checkPriceAlert(stations); } catch { if (loader && !silent) renderMapLoadError(loader, () => loadStationsAroundCenter({ silent: false })); } })(); _viewportInflight = fetchPromise; try { await fetchPromise; } finally { if (_viewportInflight === fetchPromise) _viewportInflight = null; } } // ─── Manual-scan persistence ─────────────────────────────────────── // // Results from the "Hier suchen" picker are kept in localStorage for 1 h // so the markers come back after a refresh. Each station from such a scan // carries an _expiresAt timestamp the sheet UI uses to render a countdown. const MANUAL_SCAN_TTL_MS = 60 * 60 * 1000; const MANUAL_SCANS_KEY = 'tank_manual_scans'; function loadStoredManualScans() { try { const raw = JSON.parse(localStorage.getItem(MANUAL_SCANS_KEY) || '[]'); if (!Array.isArray(raw)) return []; const now = Date.now(); return raw.filter(s => s && Number(s.expiresAt) > now && Array.isArray(s.stations)); } catch { return []; } } function persistManualScans() { try { localStorage.setItem(MANUAL_SCANS_KEY, JSON.stringify(state.manualScans || [])); } catch { /* quota exhausted — ignore */ } } function pruneManualScans() { const now = Date.now(); const before = (state.manualScans || []).length; state.manualScans = (state.manualScans || []).filter(s => Number(s.expiresAt) > now); if (state.manualScans.length !== before) { persistManualScans(); return true; } return false; } function recordManualScan(lat, lng, stations) { if (!Array.isArray(state.manualScans)) state.manualScans = []; const expiresAt = Date.now() + MANUAL_SCAN_TTL_MS; state.manualScans.push({ lat, lng, stations, expiresAt }); persistManualScans(); } // Manual scans are also exposed by the backend so any user (logged in or // not) can piggy-back on a peer's recent scan. Pull the active set in, // merge with whatever we have locally and de-dupe by rounded lat/lng so // repeated entries from the same area collapse. function manualScanKey(lat, lng) { return `${Number(lat).toFixed(2)},${Number(lng).toFixed(2)}`; } function mergeIntoManualScans(remoteScans) { if (!Array.isArray(remoteScans) || !remoteScans.length) return false; if (!Array.isArray(state.manualScans)) state.manualScans = []; const byKey = new Map(); for (const s of state.manualScans) { if (!s || Number(s.expiresAt) <= Date.now()) continue; byKey.set(manualScanKey(s.lat, s.lng), s); } let touched = false; for (const r of remoteScans) { if (!r || !Array.isArray(r.stations)) continue; const exp = Number(r.expiresAt); if (!(exp > Date.now())) continue; const key = manualScanKey(r.lat, r.lng); const existing = byKey.get(key); // Keep whichever entry expires later (= scanned more recently). if (!existing || exp > Number(existing.expiresAt)) { byKey.set(key, { lat: r.lat, lng: r.lng, stations: r.stations, expiresAt: exp }); touched = true; } } if (touched) { state.manualScans = Array.from(byKey.values()); persistManualScans(); } return touched; } let _manualScansFetchInflight = null; async function syncManualScansFromServer({ rerender = true } = {}) { if (_manualScansFetchInflight) { try { await _manualScansFetchInflight; } catch {} return; } _manualScansFetchInflight = (async () => { try { const res = await api(`/api/manual-scans?fuel=${state.fuelType}`); const scans = Array.isArray(res?.scans) ? res.scans : []; const changed = mergeIntoManualScans(scans); if (changed && rerender && state.map && Array.isArray(state.stations)) { renderStationsOnMap(state.stations, { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(state.stations); } } catch { /* offline / endpoint missing — fall back to local-only */ } })(); try { await _manualScansFetchInflight; } finally { _manualScansFetchInflight = null; } } // Drive the manual-scan freshness colour from the remaining-time fraction t // (1 = just scanned, 0 = expired). Hue interpolates green → yellow → red. // We push the value onto the sheet body so both the small banner and the // big price (sibling, .has-manual-scan) inherit and pulse in the same hue. function applyManualScanFreshness(el, t) { const clamped = Math.max(0, Math.min(1, t)); let hue; if (clamped >= 0.5) { const u = (clamped - 0.5) / 0.5; hue = 45 + u * (142 - 45); } else { const u = clamped / 0.5; hue = u * 45; } const sat = 80 + (1 - clamped) * 8; const light = 50; const color = `hsl(${hue.toFixed(1)} ${sat.toFixed(1)}% ${light}%)`; const targets = [el]; const sheetBody = document.getElementById('bottom-sheet-body'); if (sheetBody) targets.push(sheetBody); for (const target of targets) { target.style.setProperty('--scan-color', color); } } // Merge active manual-scan stations into the given list. Any station the // user manually scanned within the TTL keeps its _expiresAt tag, even when // the primary list also contains it (so the detail sheet always shows the // countdown right after a scan, not just after a refresh). // // IMPORTANT: state.manualScans now also holds peer scans pulled from // /api/manual-scans, which can sit anywhere in DE/AT. Without a proximity // gate they would pollute every render — e.g. an AT user would see // distant DE peer scans in their list. Drop scans whose centre is more // than MANUAL_SCAN_NEARBY_KM away from the current map view. const MANUAL_SCAN_NEARBY_KM = 50; function manualScansInRange() { const now = Date.now(); const center = state.map ? state.map.getCenter() : null; return (state.manualScans || []).filter((scan) => { if (!scan || Number(scan.expiresAt) <= now) return false; if (!center) return true; if (!Number.isFinite(scan.lat) || !Number.isFinite(scan.lng)) return true; return distanceKm(center.lat, center.lng, scan.lat, scan.lng) <= MANUAL_SCAN_NEARBY_KM; }); } function withManualScans(primary) { const inRange = manualScansInRange(); // Build a station-id → latest expiresAt map across all active scans. const expiryByKey = new Map(); for (const scan of inRange) { const exp = Number(scan.expiresAt); for (const s of scan.stations || []) { const key = s.id || `${s.lat},${s.lng}`; const existing = expiryByKey.get(key); if (!existing || exp > existing) expiryByKey.set(key, exp); } } const out = new Map(); const list = Array.isArray(primary) ? primary : []; for (const s of list) { const key = s.id || `${s.lat},${s.lng}`; const exp = expiryByKey.get(key); out.set(key, exp ? { ...s, _expiresAt: exp } : s); } // Add stations only present in a manual scan (not in primary at all). for (const scan of inRange) { const exp = Number(scan.expiresAt); for (const s of scan.stations || []) { const key = s.id || `${s.lat},${s.lng}`; if (out.has(key)) continue; out.set(key, { ...s, _expiresAt: exp }); } } return Array.from(out.values()); } const SEARCH_HERE_COOLDOWN_SEC = 30; const SCAN_RADIUS_KM = 25; const SCAN_ANIMATION_DURATION = 1300; const SCAN_RIPPLE_COUNT = 3; const SCAN_RIPPLE_STAGGER = 260; const SEARCH_ICON_SVG = ''; const CHECK_ICON_SVG = ''; const CLOSE_ICON_SVG = ''; function getAccentColor() { const v = getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(); return v || '#007aff'; } function setupSearchHereButton() { const btn = document.getElementById('btn-search-here'); if (!btn || btn._setup) return; btn._setup = true; btn.classList.remove('hidden'); setSearchBtnIdle(btn); btn.addEventListener('click', () => { if (!state.map || btn.disabled) return; if (state._scanPicker) confirmScanPicker(); else enterScanPickerMode(); }); // Survive a refresh: pick the cooldown back up if it's still ticking. restoreSearchHereCooldown(); } function setSearchBtnIdle(btn) { btn = btn || document.getElementById('btn-search-here'); if (!btn) return; btn.classList.remove('is-confirm'); btn.innerHTML = `${SEARCH_ICON_SVG}${t('searchHere')}`; } function setSearchBtnConfirm(btn) { btn = btn || document.getElementById('btn-search-here'); if (!btn) return; btn.classList.add('is-confirm'); btn.innerHTML = `${CHECK_ICON_SVG}${t('scanConfirm')}`; } function enterScanPickerMode() { if (state._scanPicker || !state.map) return; haptic('light'); const accent = getAccentColor(); const center = state.map.getCenter(); const circle = L.circle(center, { radius: SCAN_RADIUS_KM * 1000, color: accent, weight: 2, opacity: 0.9, fillColor: accent, fillOpacity: 0.08, interactive: false, }).addTo(state.map); const pinIcon = L.divIcon({ className: '', html: `
`, iconSize: [28, 28], iconAnchor: [14, 14], }); const marker = L.marker(center, { draggable: true, icon: pinIcon, zIndexOffset: 1000 }).addTo(state.map); marker.on('drag', () => { const p = marker.getLatLng(); circle.setLatLng(p); }); const onMapClick = (e) => { marker.setLatLng(e.latlng); circle.setLatLng(e.latlng); }; state.map.on('click', onMapClick); state._scanPicker = { circle, marker, onMapClick }; // Frame the radius so the user can see the full 25 km area at a glance. try { state.map.fitBounds(circle.getBounds(), { padding: [40, 40], animate: true }); } catch { /* fitBounds can throw on a not-yet-projected map; ignore */ } // Toggle button into confirm mode + insert cancel pill alongside it. // The confirm + cancel pills now share the top row with the search box, so // hide the box while picking — the row then centres (see CSS rule on // #map-search-top-row:has(#map-search-box.hidden)). document.getElementById('map-search-box')?.classList.add('hidden'); const btn = document.getElementById('btn-search-here'); setSearchBtnConfirm(btn); if (btn && !document.getElementById('btn-scan-cancel')) { const cancel = document.createElement('button'); cancel.id = 'btn-scan-cancel'; cancel.className = 'map-scan-cancel'; cancel.type = 'button'; cancel.setAttribute('aria-label', t('scanCancel')); cancel.innerHTML = CLOSE_ICON_SVG; cancel.addEventListener('click', cancelScanPicker); btn.parentNode.insertBefore(cancel, btn); } } function exitScanPickerMode() { const picker = state._scanPicker; if (picker) { try { state.map.removeLayer(picker.circle); } catch {} try { state.map.removeLayer(picker.marker); } catch {} if (picker.onMapClick) try { state.map.off('click', picker.onMapClick); } catch {} state._scanPicker = null; } document.getElementById('btn-scan-cancel')?.remove(); document.getElementById('map-search-box')?.classList.remove('hidden'); setSearchBtnIdle(); } function cancelScanPicker() { haptic('light'); exitScanPickerMode(); } async function confirmScanPicker() { const picker = state._scanPicker; if (!picker) return; const center = picker.marker.getLatLng(); // Don't waste a scan slot on an area already covered by an admin scan // location (or its immediate 25 km neighbourhood). Just navigate there // — loadStationsAroundCenter will pull the cached data for free. if (await isLocationAlreadyCovered(center.lat, center.lng)) { haptic('warning'); showToast(t('alreadyCovered')); exitScanPickerMode(); if (state.map) state.map.flyTo([center.lat, center.lng], 13, { duration: 0.6 }); return; } haptic('medium'); // Tear down picker UI but keep the search button visible (disabled during scan) exitScanPickerMode(); await runScanAt(center.lat, center.lng); } // Scan animation + station fetch + render. Drives the search-here button // state for both the manual picker flow and the location-search result flow. async function runScanAt(lat, lng) { state._scanRunning = true; const btn = document.getElementById('btn-search-here'); if (btn) { btn.disabled = true; btn.innerHTML = `${SEARCH_ICON_SVG}${t('searchingHere')}`; } const animPromise = playScanAnimation({ lat, lng }, SCAN_RADIUS_KM); try { const result = await api(`/api/stations?lat=${lat}&lng=${lng}&rad=${SCAN_RADIUS_KM}&fuel=${state.fuelType}`); const rawStations = Array.isArray(result) ? result : []; // The scan only changes WHAT the user is looking at — not where they // are. The blue pin stays at the device's GPS reading; distance labels // are recomputed from there (or stripped if GPS is unknown). const stations = withDistanceFromUser(rawStations); state.stations = stations; state.dataTimestamp = result._dataTimestamp || null; state._searchHereAnchor = { lat, lng }; applyCountryUi(); // Persist the manual scan so the markers and a 15 min "valid for" // countdown survive a refresh. Also push a sync so other tabs / // users pick up this fresh scan on their next render. if (stations.length) { recordManualScan(lat, lng, stations); // Fire-and-forget — the server already cached via /api/stations, // this just nudges any other open tab to grab the new entry sooner. syncManualScansFromServer({ rerender: false }); } await animPromise; // skipRadiusFilter: manual scans are user-targeted at a specific point — // filtering markers by *user-GPS* distance hides them entirely whenever // the scan happens far from the user's actual location (e.g. tapping a // station out of state). The list intentionally skips this filter for // the same reason; keep them in sync. renderStationsOnMap(stations, { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(stations); if (!stations.length) showToast(t('noStationsHere')); } catch { await animPromise; showToast(t('errorLoading')); } finally { state._scanRunning = false; startSearchHereCooldown(); } } // True while a scan is animating/fetching or its post-scan cooldown is // counting down. New scan requests must queue instead of firing. function isScanBusy() { return Boolean(state._scanRunning) || state._searchHereCooldownTimer != null; } // Drop a yellow pin to signal "queued, waiting for cooldown". Tap the pin // to remove the entry from the queue. function enqueueScan(lat, lng) { if (!state.map) return; if (!Array.isArray(state._scanQueue)) state._scanQueue = []; const pin = L.marker([lat, lng], { icon: L.divIcon({ className: '', html: '', iconSize: [24, 24], iconAnchor: [12, 12], }), zIndexOffset: 800, interactive: true, keyboard: false, }).addTo(state.map); const item = { lat, lng, pin }; pin.on('click', () => removeQueuedScan(item)); state._scanQueue.push(item); } function removeQueuedScan(item) { if (!Array.isArray(state._scanQueue)) return; const idx = state._scanQueue.indexOf(item); if (idx === -1) return; state._scanQueue.splice(idx, 1); try { state.map.removeLayer(item.pin); } catch {} } // Pop the oldest queued scan and run it. Called when the cooldown ends. function processScanQueue() { if (!Array.isArray(state._scanQueue) || !state._scanQueue.length) return; const next = state._scanQueue.shift(); try { state.map.removeLayer(next.pin); } catch {} const flyDuration = 0.6; if (state.map) state.map.flyTo([next.lat, next.lng], 13, { duration: flyDuration }); setTimeout(() => { runScanAt(next.lat, next.lng); }, flyDuration * 1000 + 50); } function playScanAnimation(centerLatLng, radiusKm) { return new Promise((resolve) => { if (!state.map) { resolve(); return; } const accent = getAccentColor(); const finalRadius = radiusKm * 1000; const total = SCAN_ANIMATION_DURATION + (SCAN_RIPPLE_COUNT - 1) * SCAN_RIPPLE_STAGGER; let done = 0; let resolved = false; const finish = () => { if (!resolved) { resolved = true; resolve(); } }; for (let i = 0; i < SCAN_RIPPLE_COUNT; i++) { setTimeout(() => { if (!state.map) { done++; if (done === SCAN_RIPPLE_COUNT) finish(); return; } const ripple = L.circle(centerLatLng, { radius: 0, color: accent, weight: 2.5, opacity: 0.9, fillColor: accent, fillOpacity: 0.18, interactive: false, }).addTo(state.map); animateRipple(ripple, finalRadius, SCAN_ANIMATION_DURATION, () => { done++; if (done === SCAN_RIPPLE_COUNT) finish(); }); }, i * SCAN_RIPPLE_STAGGER); } // Safety net so we never hang the cooldown if a tile hiccup kills RAF setTimeout(finish, total + 600); }); } function animateRipple(circle, finalRadius, duration, onDone) { const start = performance.now(); function step(now) { const t = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - t, 3); try { circle.setRadius(eased * finalRadius); circle.setStyle({ opacity: 0.9 * (1 - t * 0.7), fillOpacity: 0.18 * (1 - t), }); } catch { onDone && onDone(); return; } if (t < 1) requestAnimationFrame(step); else { try { state.map.removeLayer(circle); } catch {} onDone && onDone(); } } requestAnimationFrame(step); } // Persist the cooldown's end timestamp so a refresh doesn't reset it. const COOLDOWN_STORAGE_KEY = 'tank_search_cooldown_until'; function startSearchHereCooldown(endsAt) { const btn = document.getElementById('btn-search-here'); if (!btn) return; if (state._searchHereCooldownTimer) clearInterval(state._searchHereCooldownTimer); // Without an explicit endsAt this is a freshly-started cooldown — write // it to localStorage so the next page load can pick it back up. if (!Number.isFinite(endsAt)) { endsAt = Date.now() + SEARCH_HERE_COOLDOWN_SEC * 1000; try { localStorage.setItem(COOLDOWN_STORAGE_KEY, String(endsAt)); } catch {} } btn.disabled = true; const baseLabel = t('searchHere'); setSearchBtnIdle(btn); const tick = () => { const remaining = Math.ceil((endsAt - Date.now()) / 1000); if (remaining <= 0) { clearInterval(state._searchHereCooldownTimer); state._searchHereCooldownTimer = null; try { localStorage.removeItem(COOLDOWN_STORAGE_KEY); } catch {} btn.disabled = false; setSearchBtnIdle(btn); processScanQueue(); return; } btn.innerHTML = `${SEARCH_ICON_SVG}${baseLabel} (${remaining})`; }; tick(); state._searchHereCooldownTimer = setInterval(tick, 1000); } // Re-arm the cooldown UI from a previously stored endsAt. Called once // after the search-here button has been wired up on app boot. function restoreSearchHereCooldown() { let endsAt; try { endsAt = Number(localStorage.getItem(COOLDOWN_STORAGE_KEY)); } catch { return; } if (!Number.isFinite(endsAt)) return; if (endsAt <= Date.now()) { try { localStorage.removeItem(COOLDOWN_STORAGE_KEY); } catch {} return; } startSearchHereCooldown(endsAt); } function showSearchHereIfMoved() { // Button is always visible now; kept as a no-op hook so loadMapTab's // moveend handler can still call it without breaking. } function renderStationsOnMap(stations, { skipFitBounds = false, skipRadiusFilter = false } = {}) { // Layer in active manual scans so the markers persist for 15 min after // a refresh / pan-away. Stations already in the primary list keep their // fresh data and get no countdown. stations = withManualScans(stations); // Distance labels (sheet detail) follow GPS — strips dist if unknown. stations = withDistanceFromUser(stations); // Keep the map in lockstep with the list when the favourites-only // filter is active — markers for non-favourites would contradict it. stations = applyFavouritesOnlyFilter(stations); // Remove previous cluster group or individual markers if (state.clusterGroup) { state.map.removeLayer(state.clusterGroup); state.clusterGroup = null; } state.markers.forEach(m => state.map.removeLayer(m)); state.markers = []; if (state.userMarker) { state.map.removeLayer(state.userMarker); state.userMarker = null; } if (!stations.length) return; // Filter by user-set radius (skip when loading for viewport) const radiusKm = state.radiusKm || 25; const filtered = skipRadiusFilter ? stations : stations.filter(s => !s.dist || s.dist <= radiusKm); if (state.userLat && state.userLng) { const userIcon = L.divIcon({ className: '', html: '
', iconSize: [14, 14], iconAnchor: [7, 7] }); state.userMarker = L.marker([state.userLat, state.userLng], { icon: userIcon }).addTo(state.map); } // Use MarkerClusterGroup if available, otherwise fall back to direct markers const useCluster = typeof L.markerClusterGroup === 'function'; const cluster = useCluster ? L.markerClusterGroup({ maxClusterRadius: 50, disableClusteringAtZoom: 14, spiderfyOnMaxZoom: false, showCoverageOnHover: false, iconCreateFunction: function(clusterObj) { const count = clusterObj.getChildCount(); // Average each child's pre-computed bandRatio (cached on the marker // at creation time as _stationRatio) so a cluster gets the same // gradient as its individual pins. Doing this from a cache instead // of recomputing per child keeps the merge/split animation smooth — // the polygon point-in-country test inside stationBandRatio would // otherwise run for every child on every intermediate cluster. // Children without any band ratio contribute to price but not color. const childMarkers = clusterObj.getAllChildMarkers(); let totalPrice = 0, priceCount = 0; let totalRatio = 0, ratioCount = 0; childMarkers.forEach(function(m) { if (!m._stationPrice) return; totalPrice += m._stationPrice; priceCount++; const r = m._stationRatio; if (r != null) { totalRatio += r; ratioCount++; } }); const avgPrice = priceCount > 0 ? totalPrice / priceCount : 0; const color = ratioCount > 0 ? priceColor3(totalRatio / ratioCount) : PRICE_COLOR_NEUTRAL; const pParts = avgPrice > 0 ? formatPriceParts(avgPrice) : null; const size = count > 20 ? 56 : count > 5 ? 48 : 40; return L.divIcon({ className: '', html: `
${count} ${pParts ? `~${pParts.main}${pParts.decimal}` : ''}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2] }); } }) : null; const stationMarkers = []; filtered.forEach((s) => { if (!s.price || !s.isOpen) return; // Compute the band ratio once and reuse it for both the pin's color // and the cluster's average — the polygon point-in-country test inside // stationBandRatio is the hot path during cluster merge animations. const ratio = stationBandRatio(s.price, s._locationId, s.lat, s.lng); const color = ratio == null ? PRICE_COLOR_NEUTRAL : priceColor3(ratio); const brand = fixEnc(s.brand || s.name).substring(0, 6); const pParts = formatPriceParts(s.price); const icon = L.divIcon({ className: '', html: `
${brand}
${pParts.main}${pParts.decimal}
`, iconSize: [64, 48], iconAnchor: [32, 48] }); const marker = L.marker([s.lat, s.lng], { icon }) .on('click', () => { haptic('light'); if (state.map && s.lat && s.lng) { state.map.invalidateSize(); state.map.flyTo([s.lat, s.lng], 15, { duration: 0.6 }); } showStationSheet(s); }); // Set the marker caches BEFORE addLayer — MarkerCluster may call // iconCreateFunction synchronously if the marker lands in an // already-existing cluster at the current zoom. marker._stationPrice = s.price; marker._stationLocationId = s._locationId; marker._stationLat = s.lat; marker._stationLng = s.lng; marker._stationRatio = ratio; if (cluster) { cluster.addLayer(marker); } else { marker.addTo(state.map); } stationMarkers.push(marker); }); if (cluster) { state.map.addLayer(cluster); state.clusterGroup = cluster; } state.markers = stationMarkers; const allMarkers = state.userMarker ? [state.userMarker, ...stationMarkers] : stationMarkers; if (allMarkers.length > 1) { const group = L.featureGroup(allMarkers); const bounds = group.getBounds().pad(0.1); state.defaultBounds = bounds; if (!skipFitBounds) state.map.fitBounds(bounds); } } function updateSortButton() { const label = document.getElementById('sort-label'); const icon = document.getElementById('sort-icon'); if (!label) return; const isPrice = state.stationSort === 'price'; label.textContent = isPrice ? t('sortPrice') : t('sortDistance'); if (icon) { const path = icon.querySelector('path'); if (path) { path.setAttribute('d', isPrice ? 'M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z' : 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z'); } } } function setupStationSort() { const btn = document.getElementById('station-sort'); if (!btn) return; updateSortButton(); btn.addEventListener('click', () => { haptic('light'); state.stationSort = state.stationSort === 'price' ? 'distance' : 'price'; updateSortButton(); if (state.stations.length) renderStationList(state.stations); }); } // Shown when no band is available (cold start, API failure, sparse area, // degenerate spread). Deliberately a flat neutral so it can't be mistaken // for a heatmap signal — the price is shown, but uncoloured. const PRICE_COLOR_NEUTRAL = 'hsl(0, 0%, 72%)'; // Floor on the band's P10→P90 span used by bandRatio. With a 100 km regional // band a 3-cent local spread is meaningful, so this only kicks in for truly // degenerate clusters where every nearby station sits within a cent or two. const MIN_BAND_SPREAD = 0.03; // Radius (km) and snap step (deg) used to anchor the regional band. The // snap step must match the server's REGIONAL_SNAP_DEG so the client only // refetches when crossing a real cache-cell boundary. const PRICE_BAND_RADIUS_KM = 100; const PRICE_BAND_SNAP_DEG = 0.5; function priceBandKey(fuel, lat, lng) { const snapLat = Math.round(lat / PRICE_BAND_SNAP_DEG) * PRICE_BAND_SNAP_DEG; const snapLng = Math.round(lng / PRICE_BAND_SNAP_DEG) * PRICE_BAND_SNAP_DEG; return `${fuel}:${snapLat.toFixed(2)}:${snapLng.toFixed(2)}`; } // Fetch the regional 24h price band around (lat, lng) for the current fuel // and re-render open views once it lands. Short-circuits when the anchor // snaps into the same cache cell as the previous fetch so pans within a // ~55 km cell don't trigger network or recolouring churn. async function loadPriceBand(lat, lng) { if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; const fuel = state.fuelType; const key = priceBandKey(fuel, lat, lng); if (state.priceBand && state.priceBand.key === key && state.priceBand.band) return; try { const url = `/api/price-band?fuel=${encodeURIComponent(fuel)}` + `&lat=${lat.toFixed(4)}&lng=${lng.toFixed(4)}&radius=${PRICE_BAND_RADIUS_KM}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // Ignore late responses for a fuel the user has since switched away from. if (state.fuelType !== fuel) return; state.priceBand = { fuel, band: data.band || null, key }; if (state.stations && state.stations.length) { if (state.map) renderStationsOnMap(state.stations, { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(state.stations); } // The search dropdown isn't tied to state.stations, so recolour it too. recolorOpenSearchResults(); } catch (err) { console.warn('[priceBand] load failed:', err && err.message || err); } } // Original two-stop linear RGB green → red. Passes through olive/brown // around t≈0.5 by virtue of straight-line RGB interpolation between // (52,199,89) and (255,59,48). function priceColor3(t) { const x = Math.max(0, Math.min(1, t)); const r = Math.round(52 + x * 203); const g = Math.round(199 - x * 140); const b = Math.round(89 - x * 41); return `rgb(${r},${g},${b})`; } // Map a price to t∈[0,1] linearly across the band [P10, P90], but never // using less than MIN_BAND_SPREAD as the divisor. With the regional band // the spread is already meaningful, so MIN_BAND_SPREAD only damps truly // degenerate clusters (every nearby station within ~1 cent of each other). function bandRatio(price, band) { const { p10, p90 } = band; if (!(p90 > p10)) return 0; if (price <= p10) return 0; const spread = Math.max(p90 - p10, MIN_BAND_SPREAD); const r = (price - p10) / spread; return r >= 1 ? 1 : r; } // Ratio for a single station anchored to the regional band (P10/P90 of all // open stations within PRICE_BAND_RADIUS_KM of the map centre). The server // snaps the anchor so small pans don't shift the band; the cheapest local // station maps to green, the most expensive to red, regardless of how that // region compares to the national distribution. Returns null when no band is // available — callers render neutral grey. function stationBandRatio(price) { if (!price) return null; const band = state.priceBand && state.priceBand.band; if (!band || band.p10 == null || band.p90 == null) return null; return bandRatio(price, band); } function priceColorStable(price /* , locationId, lat, lng — kept for call-site stability */) { const r = stationBandRatio(price); return r == null ? PRICE_COLOR_NEUTRAL : priceColor3(r); } function rankColor(ratio) { // green (#34c759) → orange (#ff9500) → red (#ff3b30) if (ratio <= 0.5) { const t = ratio * 2; const r = Math.round(52 + t * 203); const g = Math.round(199 - t * 50); const b = Math.round(89 - t * 89); return `rgb(${r},${g},${b})`; } const t = (ratio - 0.5) * 2; const r = 255; const g = Math.round(149 - t * 90); const b = Math.round(0 + t * 48); return `rgb(${r},${g},${b})`; } function renderStationList(stations) { // Same merge as renderStationsOnMap — list and markers stay in sync. // The list trusts whatever the upstream loader produced: loadStationsAroundCenter, // runScanAt and loadMapTab all curate state.stations to the right area // already, so a second radius pass here would just drop legitimate entries // (e.g. DE scan-location stations slightly beyond the user's 25 km circle). stations = withManualScans(stations); // The manual-scan merge can pull in stations that still carry an old // dist field (relative to the scan centre). Re-apply the GPS-based // distance pass so dist matches the user's real location, or is // stripped entirely when GPS is unknown. stations = withDistanceFromUser(stations); const list = document.getElementById('station-list'); const countLabel = document.getElementById('station-count'); let open = stations.filter(s => s.isOpen && s.price); // Favourites-only filter: trim the list to starred stations. Applied // before the group-by-price dedupe so grouping happens within favourites. const favFilterActive = isFavouritesOnlyActive(); open = applyFavouritesOnlyFilter(open); // Group-by-price toggle: dedupe so each distinct price appears once, // keeping the closest station. Walk price-then-distance order and pick // the first of each price bucket. if (state.groupByPrice) { const byPrice = [...open].sort((a, b) => (a.price - b.price) || ((a.dist || 999) - (b.dist || 999))); const seen = new Set(); const deduped = []; for (const s of byPrice) { const key = s.price.toFixed(3); if (seen.has(key)) continue; seen.add(key); deduped.push(s); } open = deduped; } if (countLabel) { if (state.routeMode) { countLabel.classList.add('is-route'); countLabel.innerHTML = `${open.length} ${t('stationsFound')}${t('alongRoute')}`; } else { countLabel.classList.remove('is-route'); countLabel.textContent = `${open.length} ${t('stationsFound')}`; } } if (!open.length) { const emptyText = favFilterActive ? t('noFavouritesHere') : t('noOpenStations'); list.innerHTML = `
${emptyText}
`; return; } if (state.stationSort === 'distance') { open.sort((a, b) => (a.dist || 999) - (b.dist || 999)); } else { // Price sort with distance as tiebreaker — equal-priced stations show // the closer one first, which matches how a driver actually picks. open.sort((a, b) => (a.price - b.price) || ((a.dist || 999) - (b.dist || 999))); } // Star toggle in the sort bar: when active, pin every favourite to the // top of the list (relative order preserved). Off — favourites are // sorted just like any other station. if (state.favouritesOnTop) { open.sort((a, b) => { const aFav = a.id && state.favourites.includes(a.id) ? 0 : 1; const bFav = b.id && state.favourites.includes(b.id) ? 0 : 1; return aFav - bFav; }); } list.innerHTML = open.slice(0, 50).map((s, i) => { const color = priceColorStable(s.price, s._locationId, s.lat, s.lng); const dist = s.dist ? `${s.distApprox ? '~' : ''}${s.dist.toFixed(1)} km` : ''; const priceParts = formatPriceParts(s.price); const isFav = s.id && state.favourites.includes(s.id); return `
${i + 1}
${fixEnc(s.brand || s.name)}
${fixEnc(s.street)} ${s.houseNumber || ''}, ${fixEnc(s.place)}
${isFav && state.user ? `` : ''}
${priceParts.main}${priceParts.decimal ? `${priceParts.decimal}` : ''}
${dist ? `
${dist}
` : ''}
`; }).join(''); requestAnimationFrame(() => { staggerIn(list.querySelectorAll('.station-item')); }); list.querySelectorAll('.fav-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavourite(btn.dataset.stationId); }); }); list.querySelectorAll('.station-item').forEach(el => { el.addEventListener('click', () => { haptic('light'); const idx = parseInt(el.dataset.idx, 10); const station = open[idx]; document.getElementById('view-map').scrollTo({ top: 0, behavior: 'smooth' }); if (state.map && station.lat && station.lng) { setTimeout(() => { state.map.invalidateSize(); state.map.flyTo([station.lat, station.lng], 15, { duration: 0.6 }); }, 200); } showStationSheet(station); }); }); } let _expandToggleTime = 0; function toggleSheetExpanded(content) { const now = Date.now(); if (now - _expandToggleTime < 400) return; _expandToggleTime = now; state.sheetExpanded = !state.sheetExpanded; content.classList.toggle('expanded', state.sheetExpanded); if (!state.sheetExpanded) content.scrollTop = 0; haptic('light'); // Reload chart after transition so tick counts update for new size setTimeout(() => { if (state.sheetStationName) { const activeRange = document.querySelector('.sheet-toggle-btn.active'); const days = activeRange ? parseInt(activeRange.dataset.range, 10) : 1; loadSheetChart(state.sheetStationName, days, state.sheetStation?.id); } else if (state.sheetChart) { state.sheetChart.resize(); } }, 400); // Update expand button icon const btn = content.querySelector('.sheet-expand-btn'); if (btn) updateExpandBtnIcon(btn, state.sheetExpanded); } function updateExpandBtnIcon(btn, expanded) { btn.innerHTML = expanded ? '' : ''; btn.setAttribute('aria-label', expanded ? t('sheetCollapse') : t('sheetExpand')); } function setupSheetDrag(content, handleArea, backdrop, closeSheet) { if (window.matchMedia('(min-width: 900px)').matches) return; let startY = 0, currentY = 0, isDragging = false; let velocityY = 0, lastY = 0, lastTime = 0; let dragSource = null; // 'handle' or 'content' let dragDirection = null; // 'up' or 'down' function canDragFromContent() { return content.scrollTop <= 0; } function onTouchStart(e) { const isHandle = handleArea && handleArea.contains(e.target); if (!isHandle && !canDragFromContent()) return; dragSource = isHandle ? 'handle' : 'content'; dragDirection = null; startY = e.touches[0].clientY; currentY = 0; velocityY = 0; lastY = startY; lastTime = Date.now(); } function onTouchMove(e) { if (dragSource === null) return; const touchY = e.touches[0].clientY; currentY = touchY - startY; if (!isDragging) { if (Math.abs(currentY) < 10) return; // dead zone if (currentY < 0) { // Swiping up — expand from handle or content (when scrolled to top) if (state.sheetExpanded) { dragSource = null; return; } // already expanded if (dragSource === 'content' && content.scrollTop > 0) { dragSource = null; return; } dragDirection = 'up'; } else { // Swiping down if (dragSource === 'content' && content.scrollTop > 0) { dragSource = null; return; } dragDirection = 'down'; } isDragging = true; content.classList.add('dragging'); } let translateY; if (dragDirection === 'up') { translateY = currentY * 0.3; // lighter resistance pulling up } else { translateY = currentY; if (translateY < 0) { translateY = translateY * 0.15; // rubber band if pulling up while dismissing/collapsing } } content.style.transform = `translateY(${translateY}px)`; // Only fade backdrop for dismiss gesture (down + not expanded) if (dragDirection === 'down' && !state.sheetExpanded) { const sheetH = content.offsetHeight || 400; const progress = Math.max(0, Math.min(1, translateY / sheetH)); backdrop.style.opacity = String(1 - progress * 0.8); } // Track velocity const now = Date.now(); const dt = now - lastTime; if (dt > 0) { velocityY = (touchY - lastY) / dt; // px/ms } lastY = touchY; lastTime = now; e.preventDefault(); } function onTouchEnd() { if (!isDragging) { dragSource = null; return; } isDragging = false; dragSource = null; content.classList.remove('dragging'); content.classList.add('snapping'); const sheetH = content.offsetHeight || 400; if (dragDirection === 'up' && !state.sheetExpanded) { // Expand gesture const dist = Math.abs(currentY); const speed = Math.abs(velocityY); if (dist > 60 || speed > 0.3) { content.style.transform = 'translateY(0)'; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); toggleSheetExpanded(content); } else { content.style.transform = 'translateY(0)'; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); } } else if (dragDirection === 'down' && state.sheetExpanded) { // Collapse gesture const dist = currentY; const speed = velocityY; if (dist > 60 || speed > 0.3) { content.style.transform = 'translateY(0)'; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); toggleSheetExpanded(content); } else { content.style.transform = 'translateY(0)'; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); } } else if (dragDirection === 'down' && !state.sheetExpanded) { // Dismiss gesture (existing behavior) const dismiss = currentY > sheetH * 0.3 || velocityY > 0.5; if (dismiss) { content.style.transform = `translateY(${sheetH + 20}px)`; backdrop.style.opacity = '0'; haptic('light'); setTimeout(() => { content.classList.remove('snapping'); closeSheet(); }, 350); } else { content.style.transform = 'translateY(0)'; backdrop.style.opacity = ''; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); } } else { // Fallback: snap back content.style.transform = 'translateY(0)'; content.addEventListener('transitionend', () => { content.classList.remove('snapping'); }, { once: true }); } dragDirection = null; } content.addEventListener('touchstart', onTouchStart, { passive: true }); content.addEventListener('touchmove', onTouchMove, { passive: false }); content.addEventListener('touchend', onTouchEnd, { passive: true }); state._sheetDragCleanup = () => { content.removeEventListener('touchstart', onTouchStart); content.removeEventListener('touchmove', onTouchMove); content.removeEventListener('touchend', onTouchEnd); }; } function setupPullToRefresh() { const ptrContainer = document.getElementById('ptr-container'); const ptrSpinner = document.querySelector('#ptr-spinner .native-spinner'); const app = document.getElementById('app'); if (!ptrContainer || !app) return; const THRESHOLD = 70; const MAX_PULL = 130; const RESISTANCE = 0.4; const HAPTIC_TICK_INTERVAL = 18; let startY = 0; let pullY = 0; let isDragging = false; let isRefreshing = false; let lastHapticY = 0; let crossedThreshold = false; let activeView = null; let rafId = 0; function getActiveView() { return document.querySelector('.tab-view.active'); } function isSheetOpen() { const sheet = document.getElementById('bottom-sheet'); return sheet && !sheet.classList.contains('hidden'); } function applyPullTransform() { rafId = 0; const progress = Math.min(1, pullY / THRESHOLD); app.style.transform = `translateY(${pullY}px)`; ptrSpinner.style.opacity = progress; } function onTouchStart(e) { if (isRefreshing) return; if (isSheetOpen()) return; activeView = getActiveView(); if (!activeView) return; // Don't hijack Leaflet map touches if (e.target.closest('#map') || e.target.closest('.leaflet-container')) return; if (activeView.scrollTop > 0) return; startY = e.touches[0].clientY; pullY = 0; lastHapticY = 0; crossedThreshold = false; } function onTouchMove(e) { if (isRefreshing || startY === 0) return; if (!activeView) return; const touchY = e.touches[0].clientY; const rawDelta = touchY - startY; // Only pull down if (!isDragging) { if (rawDelta < 4) return; if (activeView.scrollTop > 0) { startY = 0; return; } isDragging = true; app.classList.add('ptr-pulling'); } e.preventDefault(); // Rubber-band resistance pullY = rawDelta * RESISTANCE; if (pullY > MAX_PULL) { pullY = MAX_PULL + (pullY - MAX_PULL) * 0.15; } // Batch visual updates into a single rAF if (!rafId) { rafId = requestAnimationFrame(applyPullTransform); } // Haptic ticks during drag const hapticDelta = Math.abs(pullY - lastHapticY); if (hapticDelta >= HAPTIC_TICK_INTERVAL) { haptic('selection'); lastHapticY = pullY; } // Threshold crossing haptic if (!crossedThreshold && pullY >= THRESHOLD) { crossedThreshold = true; haptic('medium'); } else if (crossedThreshold && pullY < THRESHOLD) { crossedThreshold = false; haptic('selection'); } } function onTouchEnd() { if (!isDragging) { startY = 0; activeView = null; return; } isDragging = false; startY = 0; activeView = null; if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } applyPullTransform(); app.classList.remove('ptr-pulling'); if (pullY >= THRESHOLD) { // Trigger refresh isRefreshing = true; app.classList.add('ptr-snapping', 'ptr-refreshing'); app.style.transform = 'translateY(44px)'; ptrSpinner.style.opacity = '1'; haptic('medium'); doRefresh().finally(() => { haptic('success'); isRefreshing = false; app.classList.add('ptr-snapping'); app.style.transform = ''; ptrSpinner.style.opacity = '0'; const cleanup = () => { app.classList.remove('ptr-snapping', 'ptr-refreshing'); ptrSpinner.style.opacity = ''; }; app.addEventListener('transitionend', cleanup, { once: true }); setTimeout(cleanup, 500); // fallback }); } else { // Snap back app.classList.add('ptr-snapping'); app.style.transform = ''; ptrSpinner.style.opacity = '0'; const cleanup = () => { app.classList.remove('ptr-snapping'); ptrSpinner.style.opacity = ''; }; app.addEventListener('transitionend', cleanup, { once: true }); setTimeout(cleanup, 500); } } async function doRefresh() { const tab = state.currentTab; // Reset loaded flags so data actually reloads if (state.loaded[tab]) state.loaded[tab] = false; const loadId = ++_tabLoadId; await loadTabData(tab, loadId); } document.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd, { passive: true }); } function showStationSheet(station) { const sheet = document.getElementById('bottom-sheet'); const body = document.getElementById('bottom-sheet-body'); // The map's bottom sheet only. Stats/Verlauf use openStationDetail. sheet.classList.remove('detail-modal', 'centered'); const priceParts = formatPriceParts(station.price); const color = priceColorStable(station.price, station._locationId, station.lat, station.lng); const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent); const gmapsUrl = isIOS ? `comgooglemaps://?daddr=${station.lat},${station.lng}&directionsmode=driving` : isAndroid ? `google.navigation:q=${station.lat},${station.lng}` : `https://www.google.com/maps/dir/?api=1&destination=${station.lat},${station.lng}`; const appleMapsUrl = isIOS ? `maps://?daddr=${station.lat},${station.lng}` : `https://maps.apple.com/?daddr=${station.lat},${station.lng}`; const sheetIsFav = station.id && state.favourites.includes(station.id); body.innerHTML = `
${fixEnc(station.name || station.brand)}
${fixEnc(station.brand)}
${station.id ? `` : ''}
${station._expiresAt ? `
${t('manualScanLabel')} · ${t('manualScanExpiresIn')} –:––${t('manualScanExpiresSuffix') ? ' ' + t('manualScanExpiresSuffix') : ''}
` : ''}
${priceParts.main}${priceParts.decimal ? `${priceParts.decimal}` : ''} €/L
${(station.street || station.place) ? `
${fixEnc(station.street || '')} ${station.houseNumber || ''}${(station.street || station.houseNumber) && (station.postCode || station.place) ? ', ' : ''}${station.postCode || ''} ${fixEnc(station.place || '')}
` : ''}
${station.isOpen ? t('open') : t('closed')} ${station.dist ? `${station.distApprox ? '~' : ''}${station.dist.toFixed(1)} ${t('kmAway')}` : ''}
${state.dataTimestamp ? `
${t('lastUpdated')}: ${formatDataAge(state.dataTimestamp)}
` : ''}
${(station.lat != null && station.lng != null) ? `
Google Maps ${isAndroid ? '' : ` Apple Maps `}
` : ''}
${t('priceHistory')}
`; revealSheetBlocks(body); const sheetFavBtn = body.querySelector('.sheet-fav-btn'); if (sheetFavBtn) { sheetFavBtn.addEventListener('click', () => toggleFavourite(sheetFavBtn.dataset.stationId)); } // Rescan the 25 km circle around this station: same flow as "Hier suchen" // but anchored on the tapped pin instead of the user's pick. Close the // sheet first so the scan animation on the map is visible. const refreshBtn = body.querySelector('#sheet-refresh-btn'); if (refreshBtn) { refreshBtn.addEventListener('click', () => { if (refreshBtn.disabled) return; if (isScanBusy() || state.routeMode || !state.map) return; if (!(Number.isFinite(station.lat) && Number.isFinite(station.lng))) return; haptic('light'); closeSheet(); state.map.flyTo([station.lat, station.lng], 13, { duration: 0.6 }); setTimeout(() => { runScanAt(station.lat, station.lng); }, 650); }); } // Clean up previous drag listeners if any if (state._sheetDragCleanup) { state._sheetDragCleanup(); state._sheetDragCleanup = null; } sheet.classList.remove('hidden'); sheet.setAttribute('aria-hidden', 'false'); const backdrop = sheet.querySelector('.bottom-sheet-backdrop'); const content = sheet.querySelector('.bottom-sheet-content'); content.style.transform = ''; content.classList.remove('dragging', 'snapping', 'expanded'); state.sheetExpanded = false; const handleArea = document.getElementById('sheet-handle-area'); content.querySelector('.sheet-expand-btn')?.remove(); const expandBtn = document.createElement('button'); expandBtn.className = 'sheet-expand-btn'; updateExpandBtnIcon(expandBtn, false); expandBtn.addEventListener('click', () => toggleSheetExpanded(content)); content.appendChild(expandBtn); const closeSheet = () => { if (state.sheetChart) { state.sheetChart.destroy(); state.sheetChart = null; } if (state._manualScanCountdownTimer) { clearInterval(state._manualScanCountdownTimer); state._manualScanCountdownTimer = null; } state.sheetExpanded = false; content.style.transform = ''; content.classList.remove('dragging', 'snapping', 'expanded'); content.querySelector('.sheet-expand-btn')?.remove(); content.querySelector('.sheet-desktop-close')?.remove(); backdrop.style.opacity = ''; sheet.classList.add('hidden'); sheet.setAttribute('aria-hidden', 'true'); backdrop.removeEventListener('click', closeSheet); if (state._sheetEscapeListener) { document.removeEventListener('keydown', state._sheetEscapeListener); state._sheetEscapeListener = null; } if (state._sheetDragCleanup) { state._sheetDragCleanup(); state._sheetDragCleanup = null; } if (state.defaultBounds) showResetViewBtn(); }; // Tick the "expires in" countdown + progress bar if the station came from // a manual scan. The bar starts full and depletes over the TTL window; // the banner shifts smoothly from green → yellow → red as it nears 0. const manualBanner = body.querySelector('.sheet-manual-scan-banner'); if (manualBanner) { const expiresAt = Number(manualBanner.dataset.expiresAt); const ttlMs = Number(manualBanner.dataset.ttlMs) || MANUAL_SCAN_TTL_MS; const countdownEl = manualBanner.querySelector('.sheet-manual-scan-countdown'); const barEl = manualBanner.querySelector('.sheet-manual-scan-progress-bar'); const tick = () => { const remainingMs = expiresAt - Date.now(); if (remainingMs <= 0) { if (countdownEl) countdownEl.textContent = '0:00'; if (barEl) barEl.style.width = '0%'; applyManualScanFreshness(manualBanner, 0); manualBanner.classList.add('expired'); if (state._manualScanCountdownTimer) { clearInterval(state._manualScanCountdownTimer); state._manualScanCountdownTimer = null; } // Drop the now-expired scan from cache + map. pruneManualScans(); renderStationsOnMap(state.stations || [], { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(state.stations || []); return; } const totalSec = Math.ceil(remainingMs / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; if (countdownEl) countdownEl.textContent = `${m}:${String(s).padStart(2, '0')}`; const t = Math.max(0, Math.min(1, remainingMs / ttlMs)); if (barEl) barEl.style.width = `${t * 100}%`; applyManualScanFreshness(manualBanner, t); // Last 60 s gets a faster pulse — colour is already deep red by then. manualBanner.classList.toggle('urgent', remainingMs <= 60 * 1000); }; if (state._manualScanCountdownTimer) clearInterval(state._manualScanCountdownTimer); tick(); state._manualScanCountdownTimer = setInterval(tick, 1000); } backdrop.addEventListener('click', closeSheet); // Escape closes the sheet — parity with the stats/history detail modal. if (state._sheetEscapeListener) document.removeEventListener('keydown', state._sheetEscapeListener); const onSheetEscape = (e) => { if (e.key === 'Escape') closeSheet(); }; document.addEventListener('keydown', onSheetEscape); state._sheetEscapeListener = onSheetEscape; // --- Desktop: add close button for side panel. --- if (window.matchMedia('(min-width: 900px)').matches) { content.querySelector('.sheet-desktop-close')?.remove(); const closeBtn = document.createElement('button'); closeBtn.className = 'sheet-desktop-close'; closeBtn.innerHTML = '×'; closeBtn.addEventListener('click', closeSheet); content.prepend(closeBtn); } // --- Real drag-to-dismiss. --- setupSheetDrag(content, handleArea, backdrop, closeSheet); state.sheetStationName = station.name; state.sheetStation = station; loadSheetChart(station.name, state.historyDefaultDays || 7, station.id); if (station.id) refreshStationStatus(station); document.querySelectorAll('.sheet-toggle-btn').forEach(btn => { btn.addEventListener('click', () => { if (btn.classList.contains('disabled')) return; haptic('light'); document.querySelectorAll('.sheet-toggle-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); loadSheetChart(state.sheetStationName, parseInt(btn.dataset.range, 10), state.sheetStation?.id); }); }); } /** * Parse Tankerkönig openingTimes and figure out today's schedule + next transition. * openingTimes: [{ text: "Mo-Fr", start: "06:00", end: "22:00" }, ...] * wholeDay: boolean */ function parseOpeningTimes(openingTimes, wholeDay, isOpen) { if (wholeDay) return { label: t('open24h'), todayTimes: null, allTimes: null }; if (!Array.isArray(openingTimes) || !openingTimes.length) return null; const dayAbbr = t('dayAbbr') || ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; const now = new Date(); const todayIdx = now.getDay(); // 0=Sun // Tankerkönig uses German day names/ranges like "Mo-Fr", "Sa", "So", "Feiertag" const tkDayMap = { 'mo': 1, 'di': 2, 'mi': 3, 'do': 4, 'fr': 5, 'sa': 6, 'so': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6, 'sun': 0 }; function parseDayRange(text) { const t = text.toLowerCase().trim(); // Handle "täglich" / "daily" if (t === 'täglich' || t === 'daily') return [0,1,2,3,4,5,6]; const parts = t.split(/[-–]/); if (parts.length === 2) { const from = tkDayMap[parts[0].trim()]; const to = tkDayMap[parts[1].trim()]; if (from !== undefined && to !== undefined) { const days = []; let d = from; while (true) { days.push(d); if (d === to) break; d = (d + 1) % 7; } return days; } } // Single day const single = tkDayMap[t]; if (single !== undefined) return [single]; return null; } // Build per-day schedule const schedule = {}; // dayIdx -> [{ start, end }] const allRows = []; openingTimes.forEach(ot => { const days = parseDayRange(ot.text); const entry = { start: ot.start, end: ot.end }; allRows.push({ text: ot.text, start: ot.start, end: ot.end }); if (days) { days.forEach(d => { if (!schedule[d]) schedule[d] = []; schedule[d].push(entry); }); } }); // Find today's times const todaySlots = schedule[todayIdx] || []; // Compute next transition label const fmtTime = (t_str) => { const parts = t_str.split(':'); const hm = `${parts[0]}:${parts[1]}`; return state.lang === 'de' ? `${hm} Uhr` : hm; }; let label = ''; const nowMins = now.getHours() * 60 + now.getMinutes(); if (isOpen && todaySlots.length) { // Find which slot we're in and when it ends for (const slot of todaySlots) { const [eh, em] = slot.end.split(':').map(Number); const endMins = eh * 60 + em; const [sh, sm] = slot.start.split(':').map(Number); const startMins = sh * 60 + sm; if (nowMins >= startMins && nowMins < endMins) { label = `${t('closesAt')} ${fmtTime(slot.end)}`; break; } } } else if (!isOpen && todaySlots.length) { // Find next opening today for (const slot of todaySlots) { const [sh, sm] = slot.start.split(':').map(Number); if (nowMins < sh * 60 + sm) { label = `${t('opensAt')} ${fmtTime(slot.start)}`; break; } } // If nothing left today, find tomorrow if (!label) { for (let off = 1; off <= 7; off++) { const nextDay = (todayIdx + off) % 7; if (schedule[nextDay]?.length) { label = `${t('opensAt')} ${dayAbbr[nextDay]} ${fmtTime(schedule[nextDay][0].start)}`; break; } } } } // Format all opening times for display const fmtHM = (s) => { const p = s.split(':'); return `${p[0]}:${p[1]}`; }; const allTimes = allRows.map(r => ({ text: r.text, hours: `${fmtHM(r.start)} – ${fmtHM(r.end)}${state.lang === 'de' ? ' Uhr' : ''}` })); return { label, todayTimes: todaySlots, allTimes }; } const STATION_DETAIL_TTL_MS = 60 * 60 * 1000; // Shared 1-hour cache so the map bottom-sheet and the Stats/Verlauf // detail modal don't double-fetch when the user flips between them. async function fetchStationDetailCached(id) { if (!id) return null; state._stationDetailCache = state._stationDetailCache || {}; const now = Date.now(); const cached = state._stationDetailCache[id]; if (cached && cached.expiresAt > now) return cached.detail; let detail; try { detail = await api(`/api/station/${encodeURIComponent(id)}`); } catch { return null; } if (!detail || detail.error) return null; state._stationDetailCache[id] = { detail, expiresAt: now + STATION_DETAIL_TTL_MS }; return detail; } async function refreshStationStatus(station) { if (!station?.id) return; const detail = await fetchStationDetailCached(station.id); if (!detail) return; try { const isOpen = Boolean(detail.isOpen); // Update open/closed status const dot = document.getElementById('sheet-status-dot'); const txt = document.getElementById('sheet-status-text'); if (dot && txt) { dot.style.background = isOpen ? '#34c759' : '#ff3b30'; station.isOpen = isOpen; // Parse opening times for "closes at" / "opens at" hint const hours = parseOpeningTimes(detail.openingTimes, detail.wholeDay, isOpen); let statusStr = isOpen ? t('open') : t('closed'); if (hours?.label) statusStr += ` · ${hours.label}`; txt.textContent = statusStr; // Show opening hours section const hoursContainer = document.getElementById('sheet-hours-section'); if (hoursContainer && hours?.allTimes?.length) { hoursContainer.innerHTML = `
${t('openingHours')}
${hours.allTimes.map(r => `
${r.text}${r.hours}
`).join('')}
`; document.getElementById('sheet-hours-toggle')?.addEventListener('click', () => { haptic('light'); hoursContainer.classList.toggle('expanded'); }); } } // Deliberately do NOT update station.price from detail.php here. // Tankerkönig's list.php (what the scheduler scans into our cache) // and detail.php drift out of sync — list says 1,93 €, detail says // 2,08 € for the same Aral at the same moment. Refreshing the sheet // price from detail made the bubble and the sheet disagree. Trust // the scan cache; the "Umgebung neu scannen" button is the proper // path when the user actually wants fresh prices. } catch { // Silently fail — cached data stays as fallback } } // ─── Stats / Verlauf station detail modal ────────────────────────────── // Opened when the user clicks a station in the Tankstellen-Ranking // (Stats tab) or one of the cheapest/most-expensive cards (Verlauf tab). // Shares the #bottom-sheet DOM element so only one detail surface is open // at a time, but renders its own self-contained modal — no manual-scan // banner, no "Umgebung neu scannen" button, no drag-to-dismiss, no // expand button. Just the data. async function resolveStationDetail(seed) { const { id, name, brand, fallbackPrice } = seed || {}; // Exact id-match against the live map cache. A name/brand fuzzy match // would happily return any random "ARAL" pin for a totally different // physical station. const cached = id ? (state.stations || []).find(s => s.id === id) : null; // /api/station/:id handles DE (Tankerkönig detail.php) and AT (cache // lookup) transparently — same response shape either way. const detail = id ? await fetchStationDetailCached(id) : null; const fuel = state.fuelType || 'e5'; const toNumOrNull = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : null; }; // Live scanned price wins (matches what the map shows). Fall back to // detail.php's fuel-specific price, then to whatever the ranking row // carried as a last resort. let price = null; if (cached && typeof cached.price === 'number' && cached.price > 0) price = cached.price; else if (detail && typeof detail[fuel] === 'number' && detail[fuel] > 0) price = detail[fuel]; else if (typeof fallbackPrice === 'number' && fallbackPrice > 0) price = fallbackPrice; const lat = (cached && Number.isFinite(cached.lat)) ? cached.lat : toNumOrNull(detail?.lat); const lng = (cached && Number.isFinite(cached.lng)) ? cached.lng : toNumOrNull(detail?.lng); const userLat = state.userLat; const userLng = state.userLng; let dist; let distApprox; if (cached && Number.isFinite(cached.dist) && cached.dist > 0) { dist = cached.dist; distApprox = Boolean(cached.distApprox); } else if (lat != null && lng != null && Number.isFinite(userLat) && Number.isFinite(userLng)) { dist = distanceKm(userLat, userLng, lat, lng); distApprox = true; } const isOpen = typeof detail?.isOpen === 'boolean' ? detail.isOpen : (typeof cached?.isOpen === 'boolean' ? cached.isOpen : undefined); return { id: id || detail?.id || cached?.id || '', name: cached?.name || detail?.name || name || '', brand: cached?.brand || detail?.brand || brand || '', street: detail?.street || cached?.street || '', houseNumber: detail?.houseNumber || cached?.houseNumber || '', postCode: (detail?.postCode != null && detail.postCode !== '') ? String(detail.postCode) : (cached?.postCode || ''), place: detail?.place || cached?.place || '', lat, lng, price, isOpen, openingTimes: detail?.openingTimes ?? null, wholeDay: Boolean(detail?.wholeDay), dist, distApprox, }; } function renderStationDetailSkeleton(seed) { const name = fixEnc(seed?.name || seed?.brand || ''); const brand = fixEnc(seed?.brand || ''); return `
${name}
${brand ? `
${brand}
` : ''}
`; } function renderStationDetailHtml(station) { const showPrice = station.price != null && station.price > 0; const priceParts = showPrice ? formatPriceParts(station.price) : null; const color = showPrice ? priceColorStable(station.price, station._locationId, station.lat, station.lng) : ''; const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent); const hasCoords = Number.isFinite(station.lat) && Number.isFinite(station.lng); const gmapsUrl = hasCoords ? (isIOS ? `comgooglemaps://?daddr=${station.lat},${station.lng}&directionsmode=driving` : isAndroid ? `google.navigation:q=${station.lat},${station.lng}` : `https://www.google.com/maps/dir/?api=1&destination=${station.lat},${station.lng}`) : null; const appleMapsUrl = hasCoords ? (isIOS ? `maps://?daddr=${station.lat},${station.lng}` : `https://maps.apple.com/?daddr=${station.lat},${station.lng}`) : null; const isFav = station.id && state.favourites.includes(station.id); const hours = parseOpeningTimes(station.openingTimes, station.wholeDay, Boolean(station.isOpen)); const knownOpen = typeof station.isOpen === 'boolean'; let statusLabel = ''; if (knownOpen) statusLabel = station.isOpen ? t('open') : t('closed'); if (hours?.label) statusLabel = statusLabel ? `${statusLabel} · ${hours.label}` : hours.label; const hasAddress = Boolean(station.street || station.place); const hasDist = typeof station.dist === 'number' && Number.isFinite(station.dist); return `
${fixEnc(station.name || station.brand || '')}
${station.brand ? `
${fixEnc(station.brand)}
` : ''}
${station.id ? `` : ''}
${showPrice ? `
${priceParts.main}${priceParts.decimal ? `${priceParts.decimal}` : ''} €/L
` : ''} ${hasAddress ? `
${fixEnc(station.street || '')}${station.houseNumber ? ' ' + station.houseNumber : ''}${(station.street || station.houseNumber) && (station.postCode || station.place) ? ', ' : ''}${station.postCode || ''} ${fixEnc(station.place || '')}
` : ''} ${(knownOpen || hasDist) ? `
${knownOpen ? `` : ''} ${statusLabel} ${hasDist ? `${station.distApprox ? '~' : ''}${station.dist.toFixed(1)} ${t('kmAway')}` : ''}
` : ''} ${state.dataTimestamp ? `
${t('lastUpdated')}: ${formatDataAge(state.dataTimestamp)}
` : ''} ${hours?.allTimes?.length ? `
${t('openingHours')}
${hours.allTimes.map(r => `
${fixEnc(r.text)}${r.hours}
`).join('')}
` : ''} ${gmapsUrl ? `
Google Maps ${isAndroid ? '' : ` Apple Maps `}
` : ''}
${t('priceHistory')}
`; } async function openStationDetail(seed) { const sheet = document.getElementById('bottom-sheet'); const body = document.getElementById('bottom-sheet-body'); if (!sheet || !body || !seed) return; // Clean up any prior state — also kills a map bottom-sheet if it was open if (state.sheetChart) { state.sheetChart.destroy(); state.sheetChart = null; } if (state._manualScanCountdownTimer) { clearInterval(state._manualScanCountdownTimer); state._manualScanCountdownTimer = null; } if (state._sheetDragCleanup) { state._sheetDragCleanup(); state._sheetDragCleanup = null; } if (state._detailEscapeListener) { document.removeEventListener('keydown', state._detailEscapeListener); state._detailEscapeListener = null; } sheet.classList.remove('centered'); // legacy: ensure no lingering class sheet.classList.add('detail-modal'); sheet.classList.remove('hidden'); sheet.setAttribute('aria-hidden', 'false'); const backdrop = sheet.querySelector('.bottom-sheet-backdrop'); const content = sheet.querySelector('.bottom-sheet-content'); content.style.transform = ''; content.classList.remove('dragging', 'snapping', 'expanded'); state.sheetExpanded = false; content.querySelector('.sheet-expand-btn')?.remove(); content.querySelector('.sheet-desktop-close')?.remove(); content.querySelector('.detail-modal-close')?.remove(); // Token tracks "this modal session". If the user opens another detail // while this one's data is still loading, the older one bails out. const token = (state._detailToken = (state._detailToken || 0) + 1); const closeDetail = () => { if (state.sheetChart) { state.sheetChart.destroy(); state.sheetChart = null; } if (state._detailEscapeListener) { document.removeEventListener('keydown', state._detailEscapeListener); state._detailEscapeListener = null; } content.querySelector('.detail-modal-close')?.remove(); backdrop.removeEventListener('click', closeDetail); sheet.classList.add('hidden'); sheet.setAttribute('aria-hidden', 'true'); state.sheetStation = null; state.sheetStationName = null; // Emptying the body here would leave an empty card scaling away, and // dropping .detail-modal immediately would snap it into the bottom-sheet // layout mid-flight. Both wait for the exit to finish — unless another // detail opened in the meantime, in which case that render owns the DOM. const closingToken = state._detailToken; setTimeout(() => { if (state._detailToken !== closingToken) return; if (!sheet.classList.contains('hidden')) return; sheet.classList.remove('detail-modal'); body.classList.remove('is-revealing'); body.innerHTML = ''; }, 360); }; const closeBtn = document.createElement('button'); closeBtn.className = 'detail-modal-close'; closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', t('closeHourView')); closeBtn.innerHTML = ''; closeBtn.addEventListener('click', closeDetail); content.appendChild(closeBtn); const onEscape = (e) => { if (e.key === 'Escape') closeDetail(); }; document.addEventListener('keydown', onEscape); state._detailEscapeListener = onEscape; backdrop.addEventListener('click', closeDetail); // 1. Skeleton with whatever we know from the click seed body.innerHTML = renderStationDetailSkeleton(seed); // 2. Resolve full data (cache + detail endpoint) const station = await resolveStationDetail(seed); if (token !== state._detailToken) return; // newer detail opened during fetch if (sheet.classList.contains('hidden')) return; // user closed during fetch // 3. Full render body.innerHTML = renderStationDetailHtml(station); revealSheetBlocks(body); // Favourite toggle const favBtn = body.querySelector('.sheet-fav-btn'); if (favBtn) { favBtn.addEventListener('click', () => toggleFavourite(favBtn.dataset.stationId)); } // Opening-hours expand const hoursToggle = body.querySelector('#detail-hours-toggle'); if (hoursToggle) { hoursToggle.addEventListener('click', () => { haptic('light'); document.getElementById('detail-hours-section')?.classList.toggle('expanded'); }); } // Chart range toggle body.querySelectorAll('.sheet-toggle-btn').forEach(btn => { btn.addEventListener('click', () => { if (btn.classList.contains('disabled')) return; haptic('light'); body.querySelectorAll('.sheet-toggle-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); loadSheetChart(state.sheetStationName, parseInt(btn.dataset.range, 10), state.sheetStation?.id); }); }); // 4. Chart state.sheetStation = station; state.sheetStationName = station.name; loadSheetChart(station.name, state.historyDefaultDays || 7, station.id); } // Lazy-load admin-curated scan locations once and cache them. Used to // decide whether a German station has coverage (i.e. lies within some // scan location's radius); if not, we skip the API call and surface the // "request a scan location" hint directly. async function ensureScanLocations() { if (Array.isArray(state._scanLocations)) return state._scanLocations; if (state._scanLocationsPromise) return state._scanLocationsPromise; state._scanLocationsPromise = (async () => { try { const res = await api('/api/scan-locations'); const list = Array.isArray(res?.locations) ? res.locations : []; state._scanLocations = list; return list; } catch { state._scanLocations = []; return []; } finally { state._scanLocationsPromise = null; } })(); return state._scanLocationsPromise; } // Haversine distance in km between two lat/lng pairs. function distanceKm(lat1, lng1, lat2, lng2) { const R = 6371; const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } // True if any DE scan location's radius covers this station. Austrian // stations are covered by the grid scan everywhere, so they short-circuit // to true. function isStationCovered(station, scanLocations) { if (!station || !Number.isFinite(station.lat) || !Number.isFinite(station.lng)) return false; if (isInAustria(station.lat, station.lng)) return true; const locs = Array.isArray(scanLocations) ? scanLocations : []; for (const loc of locs) { if (loc.country !== 'de') continue; const r = Number(loc.radiusKm) > 0 ? Number(loc.radiusKm) : 25; if (distanceKm(station.lat, station.lng, loc.lat, loc.lng) <= r) return true; } return false; } // True if a manual scan at (lat, lng) would be redundant — either AT // (whole country grid-scanned) or DE inside / immediately neighbouring // an existing scan location's radius (loc.radiusKm + 10 km buffer). const ALREADY_COVERED_BUFFER_KM = 10; async function isLocationAlreadyCovered(lat, lng) { if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false; if (isInAustria(lat, lng)) return true; const scanLocs = await ensureScanLocations(); for (const loc of scanLocs || []) { if (loc.country !== 'de') continue; const r = Number(loc.radiusKm) > 0 ? Number(loc.radiusKm) : 25; if (distanceKm(lat, lng, loc.lat, loc.lng) <= r + ALREADY_COVERED_BUFFER_KM) return true; } return false; } // Render the empty state for the per-station price chart. // - notCovered=true (DE only): no admin scan location covers this station, // so we'll never have history — surface the "request a scan location" CTA. // - notCovered=false: station IS covered (or AT) but we just don't have // enough rows yet; show the neutral "data is accumulating" hint. function renderSheetChartEmptyState(loadingEl, station, notCovered = false) { if (!loadingEl) return; if (!notCovered) { loadingEl.innerHTML = `${t('historyAccumulating')}`; return; } // Germany, station not covered: explain why and offer the location request flow. loadingEl.innerHTML = `
${t('stationNotScanned')}
${t('stationNotScannedHint')}
`; const btn = document.getElementById('sheet-chart-request-btn'); if (btn && station) { btn.addEventListener('click', () => { haptic('light'); const label = [station.name || station.brand, station.place].filter(Boolean).join(' · '); // Build a "Street 12, 12345 City" string from whichever pieces exist // so the request sheet's address field starts pre-filled and matches // the station the user just tapped. const streetLine = [ [station.street, station.houseNumber].filter(Boolean).join(' ').trim(), ].filter(Boolean).join(''); const cityLine = [station.postCode, station.place].filter(Boolean).join(' ').trim(); const address = [streetLine, cityLine].filter(Boolean).join(', '); openLocationRequestSheet({ lat: station.lat, lng: station.lng, name: label, address, }); }); } } function updateSheetToggleAvailability({ has24h, has7d }) { document.querySelectorAll('.sheet-toggle-btn').forEach(btn => { const range = parseInt(btn.dataset.range, 10); const enabled = range === 1 ? has24h : has7d; btn.classList.toggle('disabled', !enabled); }); } // Clips the dataset layer to a rectangle that grows from the left edge, so // the price line and its fill wipe in left-to-right while the axes, grid and // labels stay put. // // Chart.js has no built-in "draw-on" for a filled line. Its per-point x/y // animations are the usual recipe, but they animate the *data*: on a curve // with tension and `fill: true` the area morphs through some genuinely odd // shapes on its way in. Clipping the canvas instead leaves the geometry // alone and just uncovers it, which is what "the line slides in from the // left" actually looks like. const sheetChartSweepPlugin = { id: 'sheetChartSweep', beforeDatasetsDraw(chart) { const progress = chart.$sweep; if (progress == null || progress >= 1) return; const { ctx, chartArea } = chart; if (!chartArea) return; const { left, right, top, bottom } = chartArea; ctx.save(); ctx.beginPath(); // A little vertical slack: the 2 px stroke can sit right on the top or // bottom of the plot area, and a tight clip would shave it. ctx.rect(left, top - 4, (right - left) * progress, bottom - top + 8); ctx.clip(); chart.$sweepClipped = true; }, afterDatasetsDraw(chart) { if (!chart.$sweepClipped) return; chart.$sweepClipped = false; chart.ctx.restore(); }, }; // Runs the clip from 0 to 1 on an ease-in-out curve, so the line eases away // from the left edge, crosses the middle at speed and settles gently into // the right edge instead of stopping dead. // // Must be called synchronously after `new Chart(...)`: the constructor draws // once unclipped, and setting the starting progress in the same task means // the browser only ever paints the clipped result. Doing it this way round — // rather than arming the clip inside the plugin — also fails safe: if the // sweep never runs, the chart is simply fully drawn. function sweepChartIn(chart, { duration = 950, delay = 120 } = {}) { if (!chart) return; if (prefersReducedMotion()) return; chart.$sweep = 0; chart.render(); const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); const startAt = performance.now() + delay; const frame = (now) => { // destroy() nulls ctx; a range toggle or a new station swaps sheetChart. // Either way this sweep is stale and must not keep re-rendering. if (!chart.ctx || state.sheetChart !== chart) return; const t = Math.min(Math.max(now - startAt, 0) / duration, 1); chart.$sweep = easeInOut(t); chart.render(); if (t < 1) requestAnimationFrame(frame); }; requestAnimationFrame(frame); } async function loadSheetChart(stationName, days = 1, stationId) { if (state.sheetChart) { state.sheetChart.destroy(); state.sheetChart = null; } const loading = document.getElementById('sheet-chart-loading'); const canvas = document.getElementById('sheet-price-chart'); if (!loading || !canvas) return; loading.style.display = ''; loading.innerHTML = '
'; canvas.style.display = 'none'; try { // For German stations: if no admin scan location covers the station's // 25 km radius, we know we won't have meaningful history. Skip the API // call entirely and show the request CTA instead. (AT short-circuits // to "covered" inside isStationCovered.) const station = state.sheetStation; if (station && Number.isFinite(station.lat) && Number.isFinite(station.lng) && !isInAustria(station.lat, station.lng)) { const scanLocs = await ensureScanLocations(); if (!isStationCovered(station, scanLocs)) { updateSheetToggleAvailability({ has24h: false, has7d: false }); renderSheetChartEmptyState(loading, station, true); return; } } const idParam = stationId ? `&id=${encodeURIComponent(stationId)}` : ''; const fuelParam = state.fuelType ? `&fuel=${encodeURIComponent(state.fuelType)}` : ''; const data = await api(`/api/history?station=${encodeURIComponent(stationName)}${idParam}${fuelParam}`); // Backend returns an array of station entries when ≥ 2 rows exist; an // object {entries, extremes} otherwise. Treat both "no rows" cases the same. const hasSeries = Array.isArray(data) && data.length >= 2; if (!hasSeries) { updateSheetToggleAvailability({ has24h: false, has7d: false }); renderSheetChartEmptyState(loading, station, false); return; } const countWithin = (d) => { const c = new Date(); c.setDate(c.getDate() - d); return data.filter(x => new Date(x.timestamp) >= c).length; }; const has24h = countWithin(1) >= 1; const has7d = countWithin(7) >= 2; updateSheetToggleAvailability({ has24h, has7d }); // If the requested range is empty but the other has data, auto-switch // rather than showing a stale fallback (= honest UX). if (days <= 1 && !has24h && has7d) days = 7; else if (days === 7 && !has7d && has24h) days = 1; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const filtered = data.filter(d => new Date(d.timestamp) >= cutoff); if (filtered.length < 1) { loading.style.display = ''; canvas.style.display = 'none'; const msg = days <= 1 ? t('noRecent24h') : t('noRecent7d'); loading.innerHTML = `${msg}`; return; } // Sync the active toggle button with whichever range we ended up rendering. document.querySelectorAll('.sheet-toggle-btn').forEach(b => { b.classList.toggle('active', parseInt(b.dataset.range, 10) === days); }); // For 7-day view: aggregate to 1 value per day (lowest price) let chartData; if (days === 7) { const dayMap = {}; filtered.forEach(d => { const dt = new Date(d.timestamp); const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; if (!dayMap[key] || d.min_price < dayMap[key].min_price) { dayMap[key] = { timestamp: key, min_price: d.min_price }; } }); chartData = Object.values(dayMap); } else { chartData = filtered; } const hintColor = getComputedStyle(document.body).getPropertyValue('--color-hint') || '#999'; // Anchor the x-axis to a stable "last 24 h" / "last 7 d" window — without // an explicit min/max the linear scale spans only the data range, and // Chart.js's nice-numbers algorithm picks ticks in raw-ms space, which // strips down to chaotic HH:MM labels like "06:06 / 09:53 / 20:00". const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; const xMax = Date.now(); const xMin = xMax - (days <= 1 ? DAY_MS : 7 * DAY_MS); const points = chartData.map(d => ({ x: new Date(d.timestamp).getTime(), y: d.min_price })); const prices = chartData.map(d => d.min_price); const minP = Math.min(...prices); const maxP = Math.max(...prices); // Step-aligned y-axis so ticks fall on round 0.01–0.20€ values. The old // forced min/max + maxTicksLimit combo let Chart.js insert the raw // bounds as ticks alongside "nice" intermediates (e.g. 1.84 next to // 1.85), which read as noise. yMin/yMax must be step multiples so // Chart.js's stepSize lays ticks at clean boundaries. const yRange = Math.max(maxP - minP, 0.01); const Y_STEPS = [0.01, 0.02, 0.05, 0.10, 0.20]; const yStep = Y_STEPS.find(s => yRange / s <= 3) || 0.20; const yEps = yStep * 0.15; const yMin = Math.max(0, Math.floor((minP - yEps) / yStep) * yStep); const yMax = Math.ceil((maxP + yEps) / yStep) * yStep; const pad2 = (n) => String(n).padStart(2, '0'); const formatX = (ms) => { const dt = new Date(ms); if (days <= 1) return `${pad2(dt.getHours())}:${pad2(dt.getMinutes())}`; return formatShortDate(dt); }; // Manually computed evenly spaced ticks aligned to clock boundaries. // 24h → 5 ticks every 6h ending at the most recent local-hour boundary. // 7d → one tick per local midnight inside the window. const buildXTicks = () => { const out = []; if (days <= 1) { const anchor = new Date(xMax); anchor.setMinutes(0, 0, 0); const end = anchor.getTime(); for (let k = 4; k >= 0; k--) { const t = end - k * 6 * HOUR_MS; if (t >= xMin && t <= xMax) out.push({ value: t }); } } else { const anchor = new Date(xMax); anchor.setHours(0, 0, 0, 0); const end = anchor.getTime(); for (let k = 7; k >= 0; k--) { const t = end - k * DAY_MS; if (t >= xMin && t <= xMax) out.push({ value: t }); } } return out; }; loading.style.display = 'none'; canvas.style.display = 'block'; state.sheetChart = new Chart(canvas, { type: 'line', plugins: [sheetChartSweepPlugin], data: { datasets: [ { label: 'Min', data: points, borderColor: '#34c759', backgroundColor: 'rgba(52,199,89,0.08)', borderWidth: 2, fill: true, tension: 0.35, pointRadius: points.length < 2 || days === 7 ? 3 : 0, pointBackgroundColor: '#34c759', } ] }, options: { responsive: true, maintainAspectRatio: false, // Chart.js's own animation grows the line up from the axis, which // fights both the popup's scale-in and the left-to-right sweep below. // The reveal is done entirely by sheetChartSweepPlugin. animation: false, interaction: { intersect: false, mode: 'index' }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(0,0,0,0.8)', titleFont: { size: 12 }, bodyFont: { size: 12 }, callbacks: { title: (items) => { if (!items.length) return ''; const ms = items[0].parsed?.x; if (!Number.isFinite(ms)) return ''; const dt = new Date(ms); if (days <= 1) return `${pad2(dt.getHours())}:${pad2(dt.getMinutes())} Uhr`; return `${formatShortDate(dt)} ${pad2(dt.getHours())}:${pad2(dt.getMinutes())}`; }, label: (ctx) => formatPrice(ctx.parsed.y) } } }, scales: { x: { type: 'linear', min: xMin, max: xMax, afterBuildTicks: (axis) => { axis.ticks = buildXTicks(); }, ticks: { color: hintColor.trim() || '#999', font: { size: 10 }, autoSkip: false, callback: (val) => formatX(val), }, grid: { display: false } }, y: { min: yMin, max: yMax, ticks: { color: hintColor.trim() || '#999', font: { size: 10 }, stepSize: yStep, callback: v => formatPrice(v), }, grid: { color: 'rgba(128,128,128,0.08)' } } } } }); // Same task as the constructor, so the unclipped first draw never paints. sweepChartIn(state.sheetChart); } catch { updateSheetToggleAvailability({ has24h: false, has7d: false }); loading.innerHTML = `${t('noHistory')}`; } } async function loadLocationPickers() { try { const country = state.activeCountry || getActiveCountry(); const [histRes, scanRes] = await Promise.all([ api(`/api/history?locations=list&country=${country}`), api('/api/scan-locations').catch(() => null), ]); const locations = histRes && histRes.locations ? histRes.locations : []; const scanLocs = scanRes && Array.isArray(scanRes.locations) ? scanRes.locations : []; const nameById = new Map(scanLocs.map(l => [l.id, l.name])); state.availableLocations = locations; // Drop IDs that no longer match any admin scan location. They're // legacy/orphan rows in price_history (renames, deletions) — exposing // them as "Unbekannter Standort" entries just clutters the dropdown. const knownLocations = locations.filter(locId => nameById.has(locId)); // Default the picker to the scan location closest to the user when // we still have a GPS pin and the user hasn't yet overridden the // dropdown for this country. Without coords (e.g. denied permission) // we fall back to the "Alle Standorte" baseline. let autoPicked = false; if (!state.locationPickerTouched && Number.isFinite(state.userLat) && Number.isFinite(state.userLng) && knownLocations.length) { const knownIdSet = new Set(knownLocations); const candidates = scanLocs.filter(l => l.country === country && knownIdSet.has(l.id) && Number.isFinite(l.lat) && Number.isFinite(l.lng) ); if (candidates.length) { let nearest = candidates[0]; let nearestDist = distanceKm(state.userLat, state.userLng, nearest.lat, nearest.lng); for (let i = 1; i < candidates.length; i++) { const d = distanceKm(state.userLat, state.userLng, candidates[i].lat, candidates[i].lng); if (d < nearestDist) { nearestDist = d; nearest = candidates[i]; } } state.selectedLocation = nearest.id; autoPicked = true; } } [ { picker: 'history-location-picker', hint: 'history-location-hint' }, { picker: 'stats-location-picker', hint: 'stats-location-hint' }, ].forEach(({ picker: id, hint: hintId }) => { const picker = document.getElementById(id); if (!picker) return; // Keep the first "Alle Standorte" option while (picker.options.length > 1) picker.remove(1); knownLocations.forEach(locId => { const opt = document.createElement('option'); opt.value = locId; opt.textContent = nameById.get(locId); picker.appendChild(opt); }); picker.value = state.selectedLocation; enhanceSelectForDesktop(picker); // Drive the parent wrapper visibility so the hint hides with it. const wrap = picker.closest('.location-picker-row') || picker; wrap.style.display = knownLocations.length > 0 ? '' : 'none'; const hint = document.getElementById(hintId); if (hint) hint.hidden = !autoPicked || !state.selectedLocation; }); } catch { /* ignore */ } } async function fetchHistoryData() { const country = state.activeCountry || getActiveCountry(); const url = state.selectedLocation ? `/api/history?location=${encodeURIComponent(state.selectedLocation)}&country=${country}` : `/api/history?country=${country}`; try { const res = await api(url); // Support both old format (plain array) and new format ({ entries, extremes }) if (Array.isArray(res)) { state.priceExtremes = null; return res; } state.priceExtremes = res.extremes || null; return Array.isArray(res.entries) ? res.entries : []; } catch { state.priceExtremes = null; return []; } } async function loadHistoryTab() { state.loaded.history = true; showHistorySkeleton(); await loadLocationPickers(); state.history = await fetchHistoryData(); renderChart(state.history); state.historyKey = currentDataKey(); syncCountryChips(); wireCountryChips('history-country-chips'); const historyPicker = document.getElementById('history-location-picker'); if (historyPicker) { historyPicker.addEventListener('change', async () => { state.locationPickerTouched = true; state.selectedLocation = historyPicker.value; // Sync stats picker and tear down the "auto-picked" hint on both // since the user has just made their own choice. const statsPicker = document.getElementById('stats-location-picker'); if (statsPicker) statsPicker.value = state.selectedLocation; document.getElementById('history-location-hint')?.setAttribute('hidden', ''); document.getElementById('stats-location-hint')?.setAttribute('hidden', ''); state.history = await fetchHistoryData(); renderChart(state.history); state.historyKey = currentDataKey(); // Reload stats if already loaded if (state.loaded.stats) { await reloadStats(); } }); } document.querySelectorAll('#view-history .chip[data-days]').forEach(chip => { chip.addEventListener('click', () => { haptic('light'); document.querySelectorAll('#view-history .chip[data-days]').forEach(c => c.classList.remove('active')); chip.classList.add('active'); state.historyDays = parseInt(chip.dataset.days, 10); renderChart(state.history); }); }); } // History counterpart to showStatsSkeleton: shimmering bones in the hero, // the price-chart box and the summary cards while the first fetch runs. // The hero (#history-stats) and summary (#history-summary) get overwritten // by their render functions; the chart overlay is removed explicitly. function showHistorySkeleton() { const bone = (style) => `
`; const statsEl = document.getElementById('history-stats'); if (statsEl) { statsEl.style.display = ''; statsEl.innerHTML = ` `; } const chartContainer = document.querySelector('#view-history .chart-container'); if (chartContainer && !chartContainer.querySelector('.chart-skeleton')) { const sk = document.createElement('div'); sk.className = 'skeleton-bone chart-skeleton'; sk.setAttribute('aria-hidden', 'true'); chartContainer.appendChild(sk); } const summary = document.getElementById('history-summary'); if (summary) { const card = () => ` `; summary.innerHTML = `
${t('summary')}
${card()}${card()}
`; } } // Drop the chart skeleton overlay once we're about to draw (or clear) the // chart. Safe to call on every render — it's a no-op when none is present. function removeHistoryChartSkeleton() { document.querySelector('#view-history .chart-skeleton')?.remove(); } function renderChart(data) { removeHistoryChartSkeleton(); if (!data.length) { if (state.chart) { state.chart.destroy(); state.chart = null; } if (state.hourChart) { state.hourChart.destroy(); state.hourChart = null; } document.getElementById('hour-chart-section').style.display = 'none'; const statsEl = document.getElementById('history-stats'); if (statsEl) { statsEl.style.display = 'none'; statsEl.innerHTML = ''; } const summary = document.getElementById('history-summary'); const emptyCard = (label, accent) => `
${label}
–
–
`; summary.innerHTML = `
${t('noHistory')}
${t('summary')}
${emptyCard(t('lowestPrice'), 'var(--color-good)')} ${emptyCard(t('highestPrice'), 'var(--color-bad)')}
`; return; } let filtered = data; if (state.historyDays > 0) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - state.historyDays); filtered = data.filter(d => new Date(d.timestamp) >= cutoff); } if (!filtered.length) { if (state.chart) { state.chart.destroy(); state.chart = null; } if (state.hourChart) { state.hourChart.destroy(); state.hourChart = null; } document.getElementById('hour-chart-section').style.display = 'none'; const statsEl = document.getElementById('history-stats'); if (statsEl) { statsEl.style.display = 'none'; statsEl.innerHTML = ''; } const summary = document.getElementById('history-summary'); const msg = state.historyDays > 0 ? t('noRecentRange').replace('{days}', String(state.historyDays)) : t('noHistory'); summary.innerHTML = `
${msg}
`; return; } // Aggregate by day const dayMap = {}; filtered.forEach(d => { const dt = new Date(d.timestamp); const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; if (!dayMap[key]) { dayMap[key] = { key, timestamp: key, entries: [], mins: [], avgs: [], maxs: [] }; } dayMap[key].entries.push(d); dayMap[key].mins.push(d.min_price); dayMap[key].avgs.push(d.avg_price); dayMap[key].maxs.push(d.max_price); }); const daily = Object.values(dayMap).map(g => ({ key: g.key, timestamp: g.timestamp, min_price: Math.min(...g.mins), avg_price: g.avgs.reduce((a, b) => a + b, 0) / g.avgs.length, max_price: Math.max(...g.maxs), entries: g.entries, })); renderHistoryStats(data); const labels = daily.map(d => { const dt = new Date(d.timestamp); return formatShortDate(dt); }); const ctx = document.getElementById('price-chart'); const styles = getComputedStyle(document.body); const hintColor = styles.getPropertyValue('--color-hint').trim() || '#999'; const sepColor = styles.getPropertyValue('--color-separator').trim() || '#e0e0e0'; const bgSecondary = styles.getPropertyValue('--color-bg-secondary').trim() || '#f2f2f7'; // Skip the entry animation when we're swapping data (e.g. location // switch) — animating the y-axis on each switch reads as a jitter. const isUpdate = !!state.chart; if (state.chart) state.chart.destroy(); if (state.hourChart) { state.hourChart.destroy(); state.hourChart = null; } document.getElementById('hour-chart-section').style.display = 'none'; // Per-day rank colour mapped to each day's min_price — cheap → green, // expensive → red. Used for points, the latest-day glow, and the // hover tooltip's price text. const minVals = daily.map(d => d.min_price); const minLo = Math.min(...minVals); const minHi = Math.max(...minVals); const minRange = Math.max(minHi - minLo, 0.0001); const dayColor = (price) => rankColor((price - minLo) / minRange); const pointColors = daily.map(d => dayColor(d.min_price)); const lastIdx = daily.length - 1; const minBaseRadius = daily.length < 25 ? 4 : 2.5; const pointRadii = daily.map((_, i) => i === lastIdx ? 7 : minBaseRadius); const pointHoverRadii = daily.map((_, i) => i === lastIdx ? 10 : minBaseRadius + 3); // Horizontal gradient stroke — one CanvasGradient spanning the chart // width with a colour stop at each day's x position, sampled from // that day's rank colour. Matches the stats hour chart treatment so // both tabs read in the same visual language. const buildHistoryLineGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: c, chartArea } = chart; if (!chartArea) return '#ff9500'; const g = c.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const denom = Math.max(1, pointColors.length - 1); pointColors.forEach((color, i) => { g.addColorStop(Math.max(0, Math.min(1, i / denom)), color); }); return g; }; // Area fill mirrors the line: the same horizontal per-day gradient at // low alpha. A flat all-green week gets a soft green wash — a fixed // warm palette under a green line used to render as a brown block. const buildHistoryFillGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: c, chartArea } = chart; if (!chartArea) return 'rgba(255,149,0,0.12)'; const g = c.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const denom = Math.max(1, pointColors.length - 1); pointColors.forEach((color, i) => { g.addColorStop(Math.max(0, Math.min(1, i / denom)), rgbWithAlpha(color, 0.14)); }); return g; }; state.chart = new Chart(ctx, { type: 'line', data: { labels, datasets: [ { label: 'Min', data: minVals, borderColor: buildHistoryLineGradient, backgroundColor: buildHistoryFillGradient, borderWidth: 3, borderCapStyle: 'round', borderJoinStyle: 'round', tension: 0.4, fill: true, pointBackgroundColor: pointColors, pointBorderColor: bgSecondary, pointBorderWidth: 2.5, pointRadius: pointRadii, pointHoverRadius: pointHoverRadii, pointHoverBorderWidth: 3, pointHoverBorderColor: bgSecondary, }, { // Ø companion line — the hero headline talks about the average, // so the chart carries it too: thin, dashed, muted, no points. label: t('historyAvgLabel') || 'Ø', data: daily.map(d => d.avg_price), borderColor: hintColor, borderWidth: 1.25, borderDash: [4, 5], tension: 0.4, fill: false, pointRadius: 0, pointHoverRadius: 0, pointHitRadius: 0, }, ] }, options: { responsive: true, maintainAspectRatio: false, // easeOutExpo is the Chart.js twin of the app's --ease-out token, so // the line draws itself on the same curve the UI moves on. animation: isUpdate ? false : { duration: 1100, easing: 'easeOutExpo' }, // Without this, hovering snaps the active point to its larger radius. transitions: { active: { animation: { duration: 220, easing: 'easeOutExpo' } } }, interaction: { intersect: false, mode: 'index' }, onClick: (evt, elements) => { if (!elements.length) return; const idx = elements[0].index; const day = daily[idx]; if (day && day.entries.length > 1) { renderHourChart(day.entries, day.key); } }, plugins: { legend: { display: false }, tooltip: { // Custom HTML tooltip so the price can be coloured by rank // and we can stack min / avg / max in one card. enabled: false, external: (chartCtx) => { const el = ensureHistoryTooltipEl(); const tt = chartCtx.tooltip; if (!tt || tt.opacity === 0 || !tt.dataPoints || !tt.dataPoints.length) { el.classList.remove('show'); return; } const idx = tt.dataPoints[0].dataIndex; const day = daily[idx]; if (!day) { el.classList.remove('show'); return; } const dt = new Date(day.timestamp); const dayNames = t('dayNames') || []; const name = dayNames[dt.getDay()] || ''; const dateLabel = `${name} ${formatShortDate(dt)}`; const color = pointColors[idx] || '#ff9500'; const drillable = day.entries.length > 1; el.innerHTML = `
${dateLabel}
${t('historyMinLabel')}${formatPrice(day.min_price)}
${t('historyAvgLabel')} ${formatPrice(day.avg_price)} ${t('historyHighLabel')} ${formatPrice(day.max_price)}
${drillable ? `
${t('historyTooltipHint')}
` : ''} `; const rect = chartCtx.chart.canvas.getBoundingClientRect(); el.style.left = (rect.left + window.scrollX + tt.caretX) + 'px'; el.style.top = (rect.top + window.scrollY + tt.caretY) + 'px'; el.classList.add('show'); } } }, scales: { x: { ticks: { color: hintColor, font: { size: 11, family: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif' }, maxTicksLimit: 8, }, grid: { display: false }, border: { display: false }, }, y: { ticks: { color: hintColor, font: { size: 11, family: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif' }, callback: (v) => formatPrice(v), maxTicksLimit: 4, }, grid: { color: sepColor, lineWidth: 0.5 }, border: { display: false }, // Padding around the data range so the line + halo never // clip against the chart's top/bottom edges. grace: '12%', // Near-flat weeks: without explicit bounds Chart.js falls back // to a huge "nice" range (e.g. 1,60–1,90 for a constant 1,78) // and the line floats over a giant fill block. Pin a ±3 ct // window instead so flat stays visibly flat but centered. ...(minHi - minLo < 0.02 ? { suggestedMin: minLo - 0.03, suggestedMax: minHi + 0.03 } : {}), } } }, plugins: [{ id: 'historyCrosshair', afterDatasetsDraw(chart) { const active = chart.getActiveElements().filter(a => a.datasetIndex === 0); if (!active.length) return; const elPt = active[0].element; if (!elPt) return; const idx = active[0].index; const color = pointColors[idx] || '#ff9500'; const { ctx: c, chartArea } = chart; const x = elPt.x, y = elPt.y; c.save(); c.strokeStyle = rgbWithAlpha(color, 0.45); c.lineWidth = 1.25; c.setLineDash([4, 5]); c.beginPath(); c.moveTo(x, chartArea.top); c.lineTo(x, chartArea.bottom); c.stroke(); c.setLineDash([]); c.beginPath(); c.arc(x, y, 16, 0, Math.PI * 2); c.fillStyle = rgbWithAlpha(color, 0.12); c.fill(); c.beginPath(); c.arc(x, y, 10, 0, Math.PI * 2); c.fillStyle = rgbWithAlpha(color, 0.22); c.fill(); c.restore(); } }, { // Persistent soft halo around the latest "today" point so the eye // lands on where the price is right now. id: 'historyTodayGlow', afterDatasetsDraw(chart) { const meta = chart.getDatasetMeta(0); if (!meta || !meta.data || !meta.data.length) return; const last = meta.data[meta.data.length - 1]; if (!last || last.skip) return; const color = pointColors[pointColors.length - 1] || '#ff9500'; const { ctx: c } = chart; c.save(); c.beginPath(); c.arc(last.x, last.y, 14, 0, Math.PI * 2); c.fillStyle = rgbWithAlpha(color, 0.15); c.fill(); c.restore(); } }] }); // Clear tooltip + crosshair when the finger leaves the canvas. Listeners // are bound once per — the same element survives Chart.destroy(), // and the callback resolves state.chart at fire time so re-renders stay // wired correctly. if (!ctx.__hoverListenersBound) { ctx.__hoverListenersBound = true; const clear = () => clearChartHover(state.chart, document.getElementById('history-tooltip')); ctx.addEventListener('touchend', clear); ctx.addEventListener('touchcancel', clear); ctx.addEventListener('pointerleave', clear); } const summary = document.getElementById('history-summary'); // Use actual per-station extremes from station_prices if available const extremes = state.priceExtremes; const cheapestName = extremes?.cheapest?.station_name || ''; const cheapestPrice = extremes?.cheapest?.price; const cheapestId = extremes?.cheapest?.station_id || ''; const cheapestBrand = extremes?.cheapest?.station_brand || ''; const expensiveName = extremes?.mostExpensive?.station_name || ''; const expensivePrice = extremes?.mostExpensive?.price; const expensiveId = extremes?.mostExpensive?.station_id || ''; const expensiveBrand = extremes?.mostExpensive?.station_brand || ''; // Fallback to aggregated data if no extremes const minEntry = filtered.reduce((a, b) => a.min_price < b.min_price ? a : b); const maxEntry = filtered.reduce((a, b) => a.max_price > b.max_price ? a : b); const lowestStation = cheapestName || fixEnc(minEntry.station) || t('unknown'); const lowestPrice = cheapestPrice != null ? cheapestPrice : minEntry.min_price; const highestStation = expensiveName || fixEnc(maxEntry.station) || t('unknown'); const highestPrice = expensivePrice != null ? expensivePrice : maxEntry.max_price; const extremeCard = (kind, label, price, station, id, brand) => { const isLow = kind === 'low'; const colorVar = isLow ? 'var(--color-good)' : 'var(--color-bad)'; const iconPath = isLow ? ICON_PATHS.trendDown : ICON_PATHS.trendUp; const dataAttrs = id ? `data-station-id="${id}" data-station-name="${fixEnc(station)}" data-station-brand="${fixEnc(brand)}" data-station-price="${price}"` : `data-station-name="${fixEnc(station)}" data-station-price="${price}"`; return ``; }; summary.innerHTML = `
${t('summary')}
${extremeCard('low', t('lowestPrice'), lowestPrice, lowestStation, cheapestId, cheapestBrand)} ${extremeCard('high', t('highestPrice'), highestPrice, highestStation, expensiveId, expensiveBrand)}
`; // Open the same detail modal the stats ranking uses — openStationDetail // resolves cache + /api/station/:id and handles DE/AT transparently. // Animate in only on the first render so period/location refreshes // don't re-trigger the slide-in. const isFresh = !isUpdate; if (isFresh) staggerIn(summary.querySelectorAll('.history-extreme-card'), { step: 60, max: 120 }); summary.querySelectorAll('.history-extreme-card').forEach((card) => { if (card.classList.contains('is-empty')) return; card.addEventListener('click', () => { haptic('light'); const id = card.dataset.stationId; const name = card.dataset.stationName; const brand = card.dataset.stationBrand || ''; const price = parseFloat(card.dataset.stationPrice) || null; openStationDetail({ id, name, brand, fallbackPrice: price }); }); }); } function renderHistoryStats(data) { const el = document.getElementById('history-stats'); if (!el || !data || !data.length) { if (el) { el.style.display = 'none'; el.innerHTML = ''; } return; } const now = Date.now(); const dayMs = 86400000; const avgInRange = (startDaysAgo, endDaysAgo) => { const startTs = now - endDaysAgo * dayMs; const endTs = now - startDaysAgo * dayMs; const vals = []; for (const d of data) { const ts = new Date(d.timestamp).getTime(); if (ts >= startTs && ts < endTs) vals.push(d.avg_price); } return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; }; const sorted = [...data].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); const currentAvg = sorted[0]?.avg_price; const week = avgInRange(0, 7); const prevWeek = avgInRange(7, 14); const deltaWeek = (week != null && prevWeek != null) ? week - prevWeek : null; const month = avgInRange(0, 30); const prevMonth = avgInRange(30, 60); const deltaMonth = (month != null && prevMonth != null) ? month - prevMonth : null; // Period label sourced from the entire dataset's oldest timestamp so it // reflects how much history is actually available (after retention). const tsList = data.map(d => new Date(d.timestamp).getTime()).filter(ts => !isNaN(ts)); let periodLabel = ''; if (tsList.length) { const since = Math.min(...tsList); const days = Math.max(1, Math.round((Date.now() - since) / dayMs) + 1); if (days < 2) periodLabel = t('periodToday') || 'Today'; else if (days < 90) periodLabel = (t('periodLastDays') || 'Last {n} days').replace('{n}', String(days)); else { const locale = state.lang === 'en' ? 'en-US' : 'de-DE'; const dateStr = new Date(since).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }); periodLabel = (t('periodSince') || 'Since {date}').replace('{date}', dateStr); } } const trendVar = (delta) => { if (delta == null || Math.abs(delta) < 0.005) return 'var(--color-hint)'; return delta < 0 ? 'var(--color-good)' : 'var(--color-bad)'; }; const trendArrow = (delta) => { if (delta == null || Math.abs(delta) < 0.005) return '→'; return delta < 0 ? '↓' : '↑'; }; const renderPill = (delta, label) => { if (delta == null) return ''; // The coloured arrow already carries the direction — the number // itself stays unsigned so it doesn't read as "minus minus". const flat = Math.abs(delta) < 0.005; const valueLabel = flat ? formatDelta(delta) : formatDelta(Math.abs(delta)).replace(/^\+/, ''); return ` ${trendArrow(delta)} ${valueLabel} ${label} `; }; el.style.display = ''; el.innerHTML = `
${t('currentAvg')}
${currentAvg != null ? formatPrice(currentAvg) : '–'}
${(deltaWeek != null || deltaMonth != null) ? `
${renderPill(deltaWeek, t('vsLastWeek'))} ${renderPill(deltaMonth, t('vsLastMonth'))}
` : ''}
`; // Slide-in + count-up only on the very first render of the tab — // re-running them on every period-chip / location switch reads as a // jittery "everything just re-animated" effect. state.chart is the // marker: it's truthy whenever we're about to swap data (the chart // hasn't been destroyed yet at this point in renderChart). const isFresh = !state.chart; if (!isFresh) return; requestAnimationFrame(() => { const card = el.querySelector('.history-hero-card'); if (card) card.classList.add('anim-in'); const cv = el.querySelector('.history-hero-value'); if (cv) { const num = parseFloat(cv.textContent.replace(',', '.')); if (!isNaN(num) && num > 0) { countUp(cv, num, 700, (v) => formatPrice(v)); } } }); } function renderHourChart(entries, dayKey) { const section = document.getElementById('hour-chart-section'); const label = document.getElementById('hour-chart-label'); // Parse the YYYY-MM-DD key as LOCAL time — new Date('2026-07-13') is UTC // midnight, which shifts to the previous day (wrong weekday/date in the // header) for users west of UTC. const [y, m, d] = String(dayKey).split('-').map(Number); const dt = Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) ? new Date(y, m - 1, d) : new Date(dayKey); const dayNames = t('dayNames') || []; const dayName = dayNames[dt.getDay()] || ''; // The section header carries the drilled-down day plus a close button // so the user can collapse the panel without scrolling away. label.innerHTML = ` ${dayName} ${dt.toLocaleDateString(state.lang === 'en' ? 'en-US' : 'de-DE')} `; label.querySelector('.hour-chart-close')?.addEventListener('click', () => { haptic('light'); section.style.display = 'none'; if (state.hourChart) { state.hourChart.destroy(); state.hourChart = null; } }); section.style.display = ''; const sorted = [...entries].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); const hLabels = sorted.map(d => { const h = new Date(d.timestamp); return `${h.getHours()}:${String(h.getMinutes()).padStart(2, '0')}`; }); const styles = getComputedStyle(document.body); const hintColor = styles.getPropertyValue('--color-hint').trim() || '#999'; const sepColor = styles.getPropertyValue('--color-separator').trim() || '#e0e0e0'; const bgSecondary = styles.getPropertyValue('--color-bg-secondary').trim() || '#fff'; // Rank-based colour per data point — same scheme the day chart uses, // applied here to the intraday min values. const minVals = sorted.map(d => d.min_price); const minLo = Math.min(...minVals); const minHi = Math.max(...minVals); const minRange = Math.max(minHi - minLo, 0.0001); const pointColors = minVals.map(v => rankColor((v - minLo) / minRange)); if (state.hourChart) state.hourChart.destroy(); const ctx = document.getElementById('hour-chart'); // Same horizontal-stroke + warm vertical-fill recipe as the day // chart (and the stats hour chart) — keeps the visual family // consistent across the whole app. const buildHourLineGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: c, chartArea } = chart; if (!chartArea) return '#ff9500'; const g = c.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const denom = Math.max(1, pointColors.length - 1); pointColors.forEach((color, i) => { g.addColorStop(Math.max(0, Math.min(1, i / denom)), color); }); return g; }; const buildHourFillGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: c, chartArea } = chart; if (!chartArea) return 'rgba(255,149,0,0.12)'; const g = c.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const denom = Math.max(1, pointColors.length - 1); pointColors.forEach((color, i) => { g.addColorStop(Math.max(0, Math.min(1, i / denom)), rgbWithAlpha(color, 0.14)); }); return g; }; state.hourChart = new Chart(ctx, { type: 'line', data: { labels: hLabels, datasets: [{ label: 'Min', data: minVals, borderColor: buildHourLineGradient, backgroundColor: buildHourFillGradient, borderWidth: 2.5, borderCapStyle: 'round', borderJoinStyle: 'round', tension: 0.4, fill: true, pointBackgroundColor: pointColors, pointBorderColor: bgSecondary, pointBorderWidth: 1.5, pointRadius: sorted.length < 30 ? 3 : 1.5, pointHoverRadius: sorted.length < 30 ? 5 : 4, pointHoverBorderWidth: 2, pointHoverBorderColor: bgSecondary, }, { label: t('historyAvgLabel') || 'Ø', data: sorted.map(d => d.avg_price), borderColor: hintColor, borderWidth: 1.25, borderDash: [4, 5], tension: 0.4, fill: false, pointRadius: 0, pointHoverRadius: 0, pointHitRadius: 0, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 900, easing: 'easeOutExpo' }, transitions: { active: { animation: { duration: 220, easing: 'easeOutExpo' } } }, interaction: { intersect: false, mode: 'index' }, plugins: { legend: { display: false }, tooltip: { enabled: false, external: (chartCtx) => { const el = ensureHistoryTooltipEl(); const tt = chartCtx.tooltip; if (!tt || tt.opacity === 0 || !tt.dataPoints || !tt.dataPoints.length) { el.classList.remove('show'); return; } const idx = tt.dataPoints[0].dataIndex; const entry = sorted[idx]; if (!entry) { el.classList.remove('show'); return; } const t1 = new Date(entry.timestamp); const hh = String(t1.getHours()).padStart(2, '0'); const mm = String(t1.getMinutes()).padStart(2, '0'); const suffix = t('oclock'); const timeLabel = suffix ? `${hh}:${mm} ${suffix}` : `${hh}:${mm}`; const color = pointColors[idx] || '#ff9500'; el.innerHTML = `
${timeLabel}
${t('historyMinLabel')}${formatPrice(entry.min_price)}
${t('historyAvgLabel')} ${formatPrice(entry.avg_price)} ${t('historyHighLabel')} ${formatPrice(entry.max_price)}
`; const rect = chartCtx.chart.canvas.getBoundingClientRect(); el.style.left = (rect.left + window.scrollX + tt.caretX) + 'px'; el.style.top = (rect.top + window.scrollY + tt.caretY) + 'px'; el.classList.add('show'); } } }, scales: { x: { ticks: { color: hintColor, font: { size: 11 }, maxTicksLimit: 8 }, grid: { display: false }, border: { display: false }, }, y: { ticks: { color: hintColor, font: { size: 11 }, callback: (v) => formatPrice(v), maxTicksLimit: 4, }, grid: { color: sepColor, lineWidth: 0.5 }, border: { display: false }, grace: '12%', // Same near-flat clamp as the day chart — see renderChart. ...(minHi - minLo < 0.02 ? { suggestedMin: minLo - 0.03, suggestedMax: minHi + 0.03 } : {}), } } }, plugins: [{ id: 'historyHourCrosshair', afterDatasetsDraw(chart) { const active = chart.getActiveElements().filter(a => a.datasetIndex === 0); if (!active.length) return; const elPt = active[0].element; if (!elPt) return; const idx = active[0].index; const color = pointColors[idx] || '#ff9500'; const { ctx: c, chartArea } = chart; const x = elPt.x, y = elPt.y; c.save(); c.strokeStyle = rgbWithAlpha(color, 0.4); c.lineWidth = 1.25; c.setLineDash([4, 5]); c.beginPath(); c.moveTo(x, chartArea.top); c.lineTo(x, chartArea.bottom); c.stroke(); c.setLineDash([]); c.beginPath(); c.arc(x, y, 12, 0, Math.PI * 2); c.fillStyle = rgbWithAlpha(color, 0.18); c.fill(); c.restore(); } }] }); // Scroll the hour chart into view section.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Signature of the inputs the history/stats fetches depend on. When it's // unchanged we can skip a re-fetch + re-render on tab switches and keep the // cached view (and its already-finished entrance animation). function currentDataKey() { const country = state.activeCountry || getActiveCountry(); return `${country}|${state.selectedLocation || ''}`; } async function reloadStats() { const country = state.activeCountry || getActiveCountry(); const url = state.selectedLocation ? `/api/stats?location=${encodeURIComponent(state.selectedLocation)}&country=${country}` : `/api/stats?country=${country}`; const stats = await api(url); state.stats = stats; renderStats(stats); state.statsKey = currentDataKey(); } async function loadStatsTab() { state.loaded.stats = true; showStatsSkeleton(); await loadLocationPickers(); try { await reloadStats(); } catch (e) { // A failed first fetch must not leave the skeleton shimmering forever — // swap it for the empty state. statsKey stays unset, so the next tab // switch retries the load. console.error('Stats load error:', e); renderStats(null); } syncCountryChips(); wireCountryChips('stats-country-chips'); const statsPicker = document.getElementById('stats-location-picker'); if (statsPicker) { statsPicker.addEventListener('change', async () => { state.locationPickerTouched = true; state.selectedLocation = statsPicker.value; // Sync history picker and tear down the "auto-picked" hint on // both since the user has just made their own choice. const historyPicker = document.getElementById('history-location-picker'); if (historyPicker) historyPicker.value = state.selectedLocation; document.getElementById('history-location-hint')?.setAttribute('hidden', ''); document.getElementById('stats-location-hint')?.setAttribute('hidden', ''); await reloadStats(); // Reload history if already loaded if (state.loaded.history) { state.history = await fetchHistoryData(); renderChart(state.history); state.historyKey = currentDataKey(); } }); } } // Ticks a number up to its value. The curve is expo-out — the JS twin of the // --ease-out token — so the digits decelerate the same way every moving thing // in the app does, and the last few frames settle instead of stopping dead. // Counting also starts partway up rather than from zero: a price ticking from // 0,00 € reads as a slot machine, whereas starting near the value reads as // the number resolving. function countUp(el, target, duration = 700, formatter) { const render = (v) => { el.textContent = formatter ? formatter(v) : Math.round(v).toString(); }; if (prefersReducedMotion() || !isFinite(target)) { render(target); return; } const from = target * 0.82; const start = performance.now(); function frame(now) { const progress = Math.min((now - start) / duration, 1); const ease = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress); render(from + (target - from) * ease); if (progress < 1) requestAnimationFrame(frame); } requestAnimationFrame(frame); } // Structural placeholder dropped into #stats-content on first load so the // tab fills in gracefully instead of flashing from blank to a fully built // page. Mirrors renderStats' layout — same container classes, so there's no // layout shift when the real content swaps in — with shimmering bones where // the data will land. renderStats overwrites innerHTML, clearing it. function showStatsSkeleton() { const el = document.getElementById('stats-content'); if (!el) return; const bone = (style) => `
`; const factCard = () => `
${bone('width:28px;height:28px;border-radius:9px;flex-shrink:0')}
${bone('width:60%;height:9px;margin-bottom:7px')} ${bone('width:85%;height:15px')}
`; const bestCard = () => `
${bone('width:50%;height:11px')} ${bone('width:72%;height:20px;margin-top:8px')} ${bone('width:40%;height:15px;margin-top:8px')}
`; const tile = () => `
${bone('width:62%;height:9px')} ${bone('width:84%;height:13px;margin-top:5px')} ${bone('width:calc(100% - 12px);height:3px;margin-top:auto;border-radius:999px')}
`; const row = () => `
${bone('width:28px;height:28px;border-radius:8px;flex-shrink:0')}
${bone('width:55%;height:14px')}
${bone('width:52px;height:18px;margin-left:auto;flex-shrink:0')}
`; el.innerHTML = ` `; } function renderStats(stats) { const el = document.getElementById('stats-content'); // A sparse location can come back without overall/dayAvgs/hourAvgs — // treat that like "no stats" instead of throwing mid-render (which would // strand the previous location's stats on screen). if (!stats || !stats.overall || !Array.isArray(stats.dayAvgs) || !Array.isArray(stats.hourAvgs)) { el.innerHTML = `
${t('noStats')}
`; return; } const bestDay = stats.dayAvgs[0]; const worstDay = stats.dayAvgs[stats.dayAvgs.length - 1]; const bestHour = stats.hourAvgs[0]; const worstHour = stats.hourAvgs[stats.hourAvgs.length - 1]; const hourLabel = bestHour ? (t('oclock') ? `${bestHour.hour}:00 ${t('oclock')}` : `${bestHour.hour}:00`) : '–'; const dayDelta = (bestDay && worstDay && worstDay.avg > bestDay.avg) ? (worstDay.avg - bestDay.avg) : 0; const hourDelta = (bestHour && worstHour && worstHour.avg > bestHour.avg) ? (worstHour.avg - bestHour.avg) : 0; const lo = stats.overall.lowest_ever; const hi = stats.overall.highest_ever; const avg = stats.overall.avg; const spreadRange = Math.max(hi - lo, 0.0001); const avgPct = Math.max(0, Math.min(100, ((avg - lo) / spreadRange) * 100)); // Absolute amount — the badge's down-arrow already signals "cheaper". const fmtDeltaShort = (n) => Math.abs(Number(n)).toFixed(2).replace('.', ',') + '€'; // Build a period label for the hero — short days form when the range // is recent, absolute date when it stretches back further. let periodLabel = ''; if (stats.overall.since) { const sinceDate = new Date(stats.overall.since); const untilDate = stats.overall.until ? new Date(stats.overall.until) : new Date(); const daysSpan = Math.max(1, Math.round((untilDate.getTime() - sinceDate.getTime()) / 86400000) + 1); if (daysSpan < 2) { periodLabel = t('periodToday') || 'Today'; } else if (daysSpan < 90) { periodLabel = (t('periodLastDays') || 'Last {n} days').replace('{n}', String(daysSpan)); } else { const locale = state.lang === 'en' ? 'en-US' : 'de-DE'; const dateStr = sinceDate.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }); periodLabel = (t('periodSince') || 'Since {date}').replace('{date}', dateStr); } } let html = `
${t('avgPrice')}${periodLabel ? ` · ${periodLabel}` : ''}
${formatPrice(avg)}
${hi > lo ? `
${formatPrice(lo)} ${t('priceSpread')} ${formatPrice(hi)}
` : ''}
${t('lowest')}
${formatPrice(lo)}
${t('highest')}
${formatPrice(hi)}
${t('measurements')}
${stats.overall.entries}
${t('bestTimes')}
${t('cheapestDay')}
${bestDay ? (t('dayNames')[bestDay.day] || bestDay.name) : '–'}
${bestDay ? formatPrice(bestDay.avg) : '–'}
${dayDelta > 0.005 ? `
${fmtDeltaShort(dayDelta)} ${t('vsWorst')}
` : ''}
${t('cheapestHour')}
${hourLabel}
${bestHour ? formatPrice(bestHour.avg) : '–'}
${hourDelta > 0.005 ? `
${fmtDeltaShort(hourDelta)} ${t('vsWorst')}
` : ''}
`; // Weekdays: chronological 7-tile row (Mon..Sun). Each tile shows the // day + price on a clean surface and renders a thin coloured progress // bar at the bottom whose width tracks how cheap the day is. The // cheapest day also gets a subtle tinted surface, a coloured border // and a star badge. if (stats.dayAvgs.length) { const dayCount = stats.dayAvgs.length; const dayRankMap = new Map(); stats.dayAvgs.forEach((d, idx) => dayRankMap.set(d.day, idx)); const dayDisplayOrder = [1, 2, 3, 4, 5, 6, 0]; const dayAbbrev = t('dayAbbr') || []; // Colour and bar width track the *displayed* (2-decimal) price, not the // raw average or the rank. Two days that show the same number (e.g. // 1,84 €) must look identical — basing the colour on the rank would // spread tied prices across the green→red gradient even though the user // sees the exact same value. const dayDispV = (avg) => Number(Number(avg).toFixed(2)); const dayValuesArr = stats.dayAvgs.map(d => dayDispV(d.avg)); const dayMaxV = Math.max(...dayValuesArr); const dayMinV = Math.min(...dayValuesArr); // Weekday averages often differ by less than a cent. Flooring the // range keeps a 1-cent spread from exploding into "empty vs full" // bars — tiny real differences should LOOK tiny. const dayRangeV = Math.max(dayMaxV - dayMinV, 0.04); let dayTiles = ''; for (const dayNum of dayDisplayOrder) { const abbr = dayAbbrev[dayNum] || ''; const data = stats.dayAvgs.find(d => d.day === dayNum); if (!data) { dayTiles += `
${abbr}
–
`; continue; } const rank = dayRankMap.get(dayNum); const dispV = dayDispV(data.avg); // Every day whose DISPLAYED price ties the minimum counts as // cheapest — 1,89 € next to a highlighted 1,89 € must not look // like a loser. The star stays on the overall rank-1 day only. const isCheapest = dispV === dayMinV; // Green→orange→red scaled by the FLOORED range: with a 1-cent // spread the priciest day lands at a mild yellow-green instead of // screaming red, while a genuine 5+ cent spread still shows red. const ratio = dayCount > 1 ? (dispV - dayMinV) / dayRangeV : 0; const color = isCheapest ? '#34c759' : rankColor(ratio); // Minimum fill so every bar reads as "measured", growing with price. const barWidth = Math.round(14 + ratio * 86); const crown = rank === 0 ? '' : ''; const fullDayName = (t('dayNames') || [])[dayNum] || data.name || abbr; dayTiles += `
${crown}
${abbr}
${formatPrice(data.avg)}
`; } html += `
${t('weekdays')}
${dayTiles}
`; } // Hours: line chart spanning 0..23 on the x-axis. Each measured hour // is a point coloured by its rank (cheap = green, expensive = red); // unmeasured hours are skipped and the line bridges across them. if (stats.hourAvgs.length) { html += `
${t('hourRanking')}
`; } // Stations: compact ranked list. Neutral rank badges — only the // cheapest station gets the green treatment, everything else stays // quiet so the winner is obvious instead of a full rainbow. if (stats.stationRanking.length) { html += `
${t('stationRanking')}
`; const stations = stats.stationRanking.slice(0, 10); // Tie-aware: every station whose displayed Ø price matches the leader // gets the green treatment — two rows both showing 1,62 € must not // look like winner and loser. const bestDisp = stations.length ? Number(stations[0].avg).toFixed(2) : ''; stations.forEach((s, i) => { const isBest = Number(s.avg).toFixed(2) === bestDisp; const idAttr = s.id ? ` data-station-id="${s.id}"` : ''; const brandAttr = s.brand ? ` data-station-brand="${fixEnc(s.brand)}"` : ''; const avgAttr = ` data-station-avg="${s.avg}"`; // Tooltip with the lowest observed price + sample count so users // can see the ranking confidence without us cluttering the row. const minLabel = Number.isFinite(s.min) ? `↓ ${formatPrice(s.min)}` : ''; const countLabel = Number.isFinite(s.count) ? `${s.count} ${t('measurements')}` : ''; const titleParts = [`Ø ${formatPrice(s.avg)}`, minLabel, countLabel].filter(Boolean).join(' · '); html += `
${i + 1}
${fixEnc(s.station)}
Ø${formatPrice(s.avg)}
`; }); html += '
'; } html += '
'; el.innerHTML = html; // Defer animations to next frame so DOM write doesn't block tab bar requestAnimationFrame(() => { el.querySelectorAll('.metric-value').forEach(cv => { const num = parseFloat(cv.textContent.replace(',', '.')); if (!isNaN(num) && num > 0) { const isPrice = cv.textContent.includes(','); countUp(cv, num, 600, isPrice ? (v => formatPrice(v)) : (v => Math.round(v).toString())); } }); staggerIn(el.querySelectorAll('.stats-hero-card, .stats-fact-card, .best-time-card'), { step: 50 }); staggerIn(el.querySelectorAll('.stats-tile'), { step: 35 }); renderStatsHourChart(stats); el.querySelectorAll('.stats-tile-bar-fill').forEach((fill, i) => { const target = fill.style.width; if (target) { fill.style.width = '0%'; fill.style.transitionDelay = `${Math.min(i * 40, 240)}ms`; requestAnimationFrame(() => { fill.style.width = target; }); } }); staggerIn(el.querySelectorAll('.ranking-item'), { step: 25 }); // Slide the spread marker into place after layout settles const marker = el.querySelector('.stats-spread-marker'); if (marker) { const targetLeft = marker.style.left; marker.style.left = '0%'; requestAnimationFrame(() => { marker.style.left = targetLeft; }); } }); el.querySelectorAll('.station-ranking-item').forEach(item => { item.addEventListener('click', () => { haptic('light'); const name = item.dataset.stationName; const id = item.dataset.stationId; const brand = item.dataset.stationBrand || ''; const avg = parseFloat(item.dataset.stationAvg) || null; openStationDetail({ id, name, brand, fallbackPrice: avg }); }); }); // Every weekday tile is now interactive. The cheapest one keeps its // gold star burst; the others fire a small price-tag burst tinted by // their rank colour and reveal a floating chip with the full label. el.querySelectorAll('.stats-tile').forEach(tile => { if (tile.classList.contains('is-empty')) return; const color = tile.dataset.tileColor; const label = tile.dataset.tileLabel || ''; if (tile.classList.contains('is-best')) { attachConfetti(tile, ICON_PATHS.star, { fixedColor: '#34c759', count: 24, size: 16 }); } else { attachConfetti(tile, ICON_PATHS.priceTag, { fixedColor: color, count: 14, size: 14 }); } tile.addEventListener('click', () => { const r = tile.getBoundingClientRect(); if (label) showFloatingValue(r.left + r.width / 2, r.top, label, color); }); }); } // Lazily create (and reuse) a single floating tooltip element for the // stats hour chart so the price can be coloured per-hover. function ensureStatsHourTooltipEl() { let el = document.getElementById('stats-hour-tooltip'); if (!el) { el = document.createElement('div'); el.id = 'stats-hour-tooltip'; el.className = 'stats-hour-tooltip'; document.body.appendChild(el); } return el; } // Same idea for the history tab's chart (min/avg/max for the day). function ensureHistoryTooltipEl() { let el = document.getElementById('history-tooltip'); if (!el) { el = document.createElement('div'); el.id = 'history-tooltip'; el.className = 'history-tooltip'; document.body.appendChild(el); } return el; } // Clear a chart's hover state — used on touchend / pointerleave and on // tab switch so the custom tooltip + crosshair don't stick around after // the finger lifts. Chart.js' default events don't include touchend, so // without this the last-hovered point stays "active" forever on mobile. function clearChartHover(chart, tooltipEl) { if (tooltipEl) tooltipEl.classList.remove('show'); if (!chart) return; try { chart.setActiveElements([]); if (chart.tooltip) chart.tooltip.setActiveElements([], { x: 0, y: 0 }); chart.update('none'); } catch {} } // Helper: convert an "rgb(r, g, b)" string to "rgba(r, g, b, a)". function rgbWithAlpha(rgbString, alpha) { const m = rgbString.match(/\d+/g); if (!m || m.length < 3) return rgbString; return `rgba(${m[0]},${m[1]},${m[2]},${alpha})`; } // Hour-of-day price chart — line with the green→orange→red gradient // stroke sampled from each measured hour's rank, a warm vertical area // fill, and a custom hover crosshair + glow halo. The x-axis spans only // the measured hours so sparse data still fills the card edge-to-edge. function renderStatsHourChart(stats) { const canvas = document.getElementById('stats-hour-chart'); if (!canvas || !stats.hourAvgs.length) return; const hourMap = new Map(); stats.hourAvgs.forEach((h, idx) => hourMap.set(h.hour, { ...h, rank: idx })); const hourCount = stats.hourAvgs.length; const measured = stats.hourAvgs.map(h => h.hour); const firstHour = Math.min(...measured); const lastHour = Math.max(...measured); const hourVals = stats.hourAvgs.map(h => h.avg); const hourLo = Math.min(...hourVals); const hourHi = Math.max(...hourVals); const labels = []; const data = []; const pointBg = []; // colour per hour, null = no data const pointRadii = []; const pointHoverRadii = []; for (let hour = firstHour; hour <= lastHour; hour++) { labels.push(`${hour}:00`); const d = hourMap.get(hour); if (!d) { data.push(null); pointBg.push(null); pointRadii.push(0); pointHoverRadii.push(0); continue; } const ratio = hourCount > 1 ? d.rank / (hourCount - 1) : 0; pointBg.push(rankColor(ratio)); data.push(d.avg); const r = d.rank === 0 ? 7 : 4; pointRadii.push(r); pointHoverRadii.push(r + 3); } const slotCount = labels.length; const styles = getComputedStyle(document.body); const hintColor = styles.getPropertyValue('--color-hint').trim() || '#999'; const sepColor = styles.getPropertyValue('--color-separator').trim() || '#e0e0e0'; const bgColor = styles.getPropertyValue('--color-bg-secondary').trim() || '#fff'; const isUpdate = !!state.statsHourChart; if (state.statsHourChart) state.statsHourChart.destroy(); // Horizontal gradient stroke: one CanvasGradient spanning the chart // width with a colour stop at each measured hour's x position. The // line smoothly transitions between the hours' rank colours. const buildLineGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: canvasCtx, chartArea } = chart; if (!chartArea) return '#ff9500'; const g = canvasCtx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const stops = []; for (let i = 0; i < slotCount; i++) { if (pointBg[i]) stops.push({ pos: slotCount > 1 ? i / (slotCount - 1) : 0, color: pointBg[i] }); } if (!stops.length) return '#ff9500'; if (stops.length === 1) return stops[0].color; stops.forEach(s => g.addColorStop(Math.max(0, Math.min(1, s.pos)), s.color)); return g; }; // Area fill mirrors the line: same horizontal per-hour gradient at low // alpha, so a flat all-green day washes green instead of muddy brown. const buildFillGradient = (chartCtx) => { const chart = chartCtx.chart; const { ctx: canvasCtx, chartArea } = chart; if (!chartArea) return 'rgba(255,149,0,0.12)'; const g = canvasCtx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); const stops = []; for (let i = 0; i < slotCount; i++) { if (pointBg[i]) stops.push({ pos: slotCount > 1 ? i / (slotCount - 1) : 0, color: pointBg[i] }); } if (!stops.length) return 'rgba(255,149,0,0.12)'; stops.forEach(s => g.addColorStop(Math.max(0, Math.min(1, s.pos)), rgbWithAlpha(s.color, 0.14))); return g; }; state.statsHourChart = new Chart(canvas, { type: 'line', data: { labels, datasets: [{ label: t('avgPrice') || 'Avg', data, borderColor: buildLineGradient, backgroundColor: buildFillGradient, borderWidth: 3, borderCapStyle: 'round', borderJoinStyle: 'round', tension: 0.4, spanGaps: true, fill: true, pointBackgroundColor: pointBg.map(c => c || 'rgba(0,0,0,0)'), pointBorderColor: bgColor, pointBorderWidth: 2.5, pointRadius: pointRadii, pointHoverRadius: pointHoverRadii, pointHoverBorderWidth: 3, pointHoverBorderColor: bgColor, }] }, options: { responsive: true, maintainAspectRatio: false, animation: isUpdate ? false : { duration: 1300, easing: 'easeOutExpo', }, transitions: { active: { animation: { duration: 220, easing: 'easeOutExpo' } } }, interaction: { intersect: false, mode: 'index' }, hover: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { // Replace Chart.js's flat tooltip with a custom HTML tooltip // so the price text can carry the hovered hour's rank colour // (Chart.js doesn't allow per-tooltip body colours otherwise). enabled: false, external: (ctx) => { const el = ensureStatsHourTooltipEl(); const tt = ctx.tooltip; if (!tt || tt.opacity === 0 || !tt.dataPoints || !tt.dataPoints.length) { el.classList.remove('show'); return; } const dp = tt.dataPoints[0]; if (dp.parsed.y == null) { el.classList.remove('show'); return; } const idx = dp.dataIndex; const color = pointBg[idx] || '#ff9500'; const suffix = t('oclock'); const hour = firstHour + idx; const hourLabel = suffix ? `${hour}:00 ${suffix}` : `${hour}:00`; el.innerHTML = `
${hourLabel}
${formatPrice(dp.parsed.y)}
`; const rect = ctx.chart.canvas.getBoundingClientRect(); el.style.left = (rect.left + window.scrollX + tt.caretX) + 'px'; el.style.top = (rect.top + window.scrollY + tt.caretY) + 'px'; el.classList.add('show'); } } }, scales: { x: { ticks: { color: hintColor, font: { size: 11, family: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif' }, autoSkip: false, maxRotation: 0, callback: (_value, index) => { const hour = firstHour + index; return hour % 6 === 0 ? `${hour}:00` : ''; }, }, grid: { color: sepColor, drawTicks: false, lineWidth: 0.5 }, border: { display: false }, }, y: { display: false, beginAtZero: false, // Breathing room above and below the actual range so the // line never touches the chart edges (the lowest/highest // hour was getting clipped against the bottom). grace: '18%', // Near-flat days: pin a small window so the line sits centered // instead of Chart.js exploding the scale around a constant. ...(hourHi - hourLo < 0.02 ? { suggestedMin: hourLo - 0.03, suggestedMax: hourHi + 0.03 } : {}), } } }, plugins: [{ // Custom hover decoration: vertical dashed crosshair tinted by the // hovered hour's rank colour, plus two soft halos around the point // for an at-a-glance "this is what you're hovering" cue. id: 'statsHourCrosshair', afterDatasetsDraw(chart) { const active = chart.getActiveElements(); if (!active.length) return; const el = active[0].element; if (!el || el.skip) return; const idx = active[0].index; const color = pointBg[idx]; if (!color) return; const { ctx, chartArea } = chart; const x = el.x; const y = el.y; const rgb = color; ctx.save(); // Dashed vertical crosshair ctx.strokeStyle = rgbWithAlpha(rgb, 0.45); ctx.lineWidth = 1.25; ctx.setLineDash([4, 5]); ctx.beginPath(); ctx.moveTo(x, chartArea.top); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); ctx.setLineDash([]); // Two-ring halo around the point ctx.beginPath(); ctx.arc(x, y, 16, 0, Math.PI * 2); ctx.fillStyle = rgbWithAlpha(rgb, 0.12); ctx.fill(); ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.fillStyle = rgbWithAlpha(rgb, 0.22); ctx.fill(); ctx.restore(); } }] }); if (!canvas.__hoverListenersBound) { canvas.__hoverListenersBound = true; const clear = () => clearChartHover(state.statsHourChart, document.getElementById('stats-hour-tooltip')); canvas.addEventListener('touchend', clear); canvas.addEventListener('touchcancel', clear); canvas.addEventListener('pointerleave', clear); } } function setupSettings() { const fuelChips = document.getElementById('fuel-chips'); fuelChips.querySelectorAll('.chip').forEach(chip => { chip.classList.toggle('active', chip.dataset.fuel === state.fuelType); chip.addEventListener('click', async () => { haptic('light'); fuelChips.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); state.fuelType = chip.dataset.fuel; state.loaded.map = false; // Different fuel ⇒ different price band; refetch and drop the old one // so the heatmap stays neutral until the new band lands. state.priceBand = { fuel: null, band: null, key: null }; if (state.map) { const c = state.map.getCenter(); loadPriceBand(c.lat, c.lng); } persistStateSettings({ fuelType: state.fuelType }); if (state.currentTab === 'map') loadMapTab(); // If the station sheet is open, reload its chart so it tracks the // new fuel type instead of showing a stale series. if (state.sheetStationName) { const activeRange = document.querySelector('.sheet-toggle-btn.active'); const days = activeRange ? parseInt(activeRange.dataset.range, 10) : (state.historyDefaultDays || 7); loadSheetChart(state.sheetStationName, days, state.sheetStation?.id); } refreshAlertUi(); }); }); initAlertUI(); // Easter eggs in the About section — the app icon bursts copies of // itself, and the heart in the footer rains hearts. attachConfetti(document.querySelector('.about-app-icon'), null, { imgSrc: '/icons/icon-192.png', imgRadius: 6, count: 24, size: 24, }); attachConfetti(document.querySelector('.about-heart'), ICON_PATHS.heart, { fixedColor: '#ff3b30', count: 28, size: 18, }); // Contributors collapsible const contribToggle = document.getElementById('about-contributors-toggle'); const contribList = document.getElementById('about-contributors-list'); if (contribToggle && contribList) { const contribCount = contribList.querySelectorAll('.about-contributor').length; const chevron = contribToggle.querySelector('.about-contributors-chevron'); // Use saved preference, or default to open if ≤3 contributors const shouldOpen = typeof state.contributorsOpen === 'boolean' ? state.contributorsOpen : contribCount <= 3; if (shouldOpen) { contribList.classList.add('open'); if (chevron) chevron.classList.add('open'); } contribToggle.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false'); contribToggle.addEventListener('click', () => { haptic('light'); const isOpen = contribList.classList.toggle('open'); if (chevron) chevron.classList.toggle('open', isOpen); contribToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); state.contributorsOpen = isOpen; persistStateSettings({ contributorsOpen: isOpen }); }); } } function setupMapSearch() { const input = document.getElementById('map-search-input'); const clearBtn = document.getElementById('map-search-clear'); const resultsEl = document.getElementById('map-search-results'); if (!input || input._searchSetup) return; input._searchSetup = true; let debounceTimer = null; input.addEventListener('input', () => { const q = input.value.trim(); clearBtn.classList.toggle('hidden', !q); clearTimeout(debounceTimer); if (!q) { resultsEl.classList.add('hidden'); return; } debounceTimer = setTimeout(() => searchLocation(q), 350); }); clearBtn.addEventListener('click', () => { input.value = ''; clearBtn.classList.add('hidden'); resultsEl.classList.add('hidden'); }); document.addEventListener('click', (e) => { if (!e.target.closest('#map-search-wrapper')) { resultsEl.classList.add('hidden'); } }); input.addEventListener('focus', () => { if (resultsEl.children.length && input.value.trim()) { resultsEl.classList.remove('hidden'); } }); // Keyboard operability (combobox pattern): arrows move a highlight over // the results, Enter selects, Escape closes. Previously mouse/touch-only. input.setAttribute('role', 'combobox'); input.setAttribute('aria-controls', 'map-search-results'); input.setAttribute('aria-autocomplete', 'list'); const setActiveResult = (items, idx) => { items.forEach((el, i) => { el.classList.toggle('kb-active', i === idx); if (i === idx) { if (!el.id) el.id = `map-search-opt-${i}`; input.setAttribute('aria-activedescendant', el.id); el.scrollIntoView({ block: 'nearest' }); } }); if (idx < 0) input.removeAttribute('aria-activedescendant'); }; input.addEventListener('keydown', (e) => { if (resultsEl.classList.contains('hidden')) return; const items = Array.from(resultsEl.querySelectorAll('.map-search-result-item')); if (e.key === 'Escape') { resultsEl.classList.add('hidden'); input.removeAttribute('aria-activedescendant'); return; } if (!items.length) return; const current = items.findIndex(el => el.classList.contains('kb-active')); if (e.key === 'ArrowDown') { e.preventDefault(); setActiveResult(items, (current + 1) % items.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveResult(items, current <= 0 ? items.length - 1 : current - 1); } else if (e.key === 'Enter' && current >= 0) { e.preventDefault(); items[current].click(); } }); } // Match the user's query against the currently loaded stations. Ranking // mirrors renderStationList: open + priced stations sorted by price (with // distance as tiebreaker), so the rank badge in the search dropdown is // the same number the user would see in the list below. function matchStationsByQuery(query) { const stations = Array.isArray(state.stations) ? state.stations : []; const open = stations.filter(s => s.isOpen && s.price != null && s.price > 0); if (!open.length) return []; const ranked = [...open].sort((a, b) => (a.price - b.price) || ((a.dist || 999) - (b.dist || 999))); const stationKey = (s) => s.id || `${s.lat},${s.lng}`; const rankByKey = new Map(); ranked.forEach((s, i) => rankByKey.set(stationKey(s), i + 1)); const q = query.toLowerCase(); const scored = []; for (const s of open) { const name = (s.name || '').toLowerCase(); const brand = (s.brand || '').toLowerCase(); const street = (s.street || '').toLowerCase(); const place = (s.place || '').toLowerCase(); let score = 0; if (brand && brand.startsWith(q)) score = 100; else if (name && name.startsWith(q)) score = 90; else if (brand && brand.includes(q)) score = 80; else if (name && name.includes(q)) score = 70; else if (street.includes(q)) score = 50; else if (place.includes(q)) score = 40; if (!score) continue; scored.push({ station: s, rank: rankByKey.get(stationKey(s)) || null, score }); } scored.sort((a, b) => (b.score - a.score) || (a.station.price - b.station.price)); return scored.slice(0, 6); } function renderStationSearchResultsHtml(matches) { if (!matches.length) return ''; return matches.map((m, i) => { const s = m.station; const color = priceColorStable(s.price, s._locationId, s.lat, s.lng); const priceParts = formatPriceParts(s.price); const distLabel = s.dist ? `${s.distApprox ? '~' : ''}${s.dist.toFixed(1)} km` : ''; const addrParts = [ [fixEnc(s.street || ''), s.houseNumber || ''].filter(Boolean).join(' ').trim(), fixEnc(s.place || ''), ].filter(Boolean).join(', '); const meta = [distLabel, addrParts].filter(Boolean).join(' · '); return `
${m.rank ?? '–'}
${fixEnc(s.brand || s.name || '')}
${meta}
${priceParts.main}${priceParts.decimal ? `${priceParts.decimal}` : ''}
`; }).join(''); } function renderLocationSearchResultsHtml(items) { if (!items.length) return ''; return items.map(item => `
${escapeHtml(item.display_name)}
`).join(''); } function attachStationSearchHandlers(resultsEl, matches) { const input = document.getElementById('map-search-input'); const clearBtn = document.getElementById('map-search-clear'); resultsEl.querySelectorAll('.map-search-station-item').forEach(el => { el.addEventListener('click', () => { const idx = parseInt(el.dataset.stationIdx, 10); const station = matches[idx]?.station; if (!station) return; haptic('light'); input.value = station.brand || station.name || ''; clearBtn.classList.remove('hidden'); resultsEl.classList.add('hidden'); document.getElementById('map-location-banner')?.classList.add('hidden'); if (Number.isFinite(station.lat) && Number.isFinite(station.lng) && state.map) { state.map.flyTo([station.lat, station.lng], 15, { duration: 0.6 }); } showStationSheet(station); }); }); } function attachLocationSearchHandlers(resultsEl) { resultsEl.querySelectorAll('.map-search-result-item:not(.map-search-station-item)').forEach(el => { el.addEventListener('click', async () => { const lat = parseFloat(el.dataset.lat); const lng = parseFloat(el.dataset.lng); document.getElementById('map-search-input').value = el.querySelector('span').textContent; document.getElementById('map-search-clear').classList.remove('hidden'); resultsEl.classList.add('hidden'); // Hide location banner since user picked a location document.getElementById('map-location-banner')?.classList.add('hidden'); // Tear down any open picker so the ripple is the only overlay if (state._scanPicker) exitScanPickerMode(); state.loaded.map = true; // Austria has live E-Control coverage everywhere — no scan, no queue, // no cooldown. Just fly to the spot and load prices directly. Same // applies for German locations that already sit inside (or within // 25 km of) an existing scan location's radius — manually scanning // there would be wasted, the data is already in the cache. // The user's pin (state.userLat/Lng) is NOT moved by a search — the // search only changes the map view, not the user's actual location. const covered = await isLocationAlreadyCovered(lat, lng); if (covered) { applyCountryUi(); if (state.map) state.map.flyTo([lat, lng], 13, { duration: 0.6 }); await new Promise(r => setTimeout(r, 650)); // viewport refresh fires from the moveend hook after flyTo finishes. return; } // Germany flow: queue if a scan is running or the cooldown is still ticking. if (isScanBusy()) { enqueueScan(lat, lng); return; } const flyDuration = 0.6; if (state.map) state.map.flyTo([lat, lng], 13, { duration: flyDuration }); await new Promise(r => setTimeout(r, flyDuration * 1000 + 50)); await runScanAt(lat, lng); }); }); } // Monotonic counter so only the latest search's async results get rendered. // Fast typing fires several searchLocation calls and the place/station lookups // can resolve out of order — without this an old response could clobber a newer. let _searchSeq = 0; // Last rendered dropdown results, kept so the dropdown can be recoloured if the // regional price band loads after the results were first drawn. let _lastSearchResults = null; // Places come only from DE/AT — countrycodes keeps Nominatim from returning // worldwide noise. Called directly (not via /api/geocode) so search keeps // working for anonymous users, same posture as /api/stations. async function fetchPlaceResults(query) { try { const resp = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=8&countrycodes=de,at&accept-language=${state.lang || 'de'}`); const data = await resp.json(); return Array.isArray(data) ? data : []; } catch { return []; } } // Searches every cached station server-side, not just the current viewport, // so a station can be found anywhere the scanner has data. async function fetchServerStationMatches(query) { const fuel = state.fuelType || 'diesel'; let url = `/api/stations/search?q=${encodeURIComponent(query)}&fuel=${encodeURIComponent(fuel)}`; if (state.userLat != null && state.userLng != null) { url += `&lat=${state.userLat}&lng=${state.userLng}`; } try { const data = await api(url); return Array.isArray(data) ? data : []; } catch { return []; } } // Combine the instant local (viewport) hits with the server-wide hits. Local // matches win on dedupe so they keep their list rank; server-only stations are // appended without a rank badge. function mergeStationMatches(localMatches, serverStations) { const keyOf = (s) => s.id || `${s.lat},${s.lng}`; const seen = new Set(localMatches.map(m => keyOf(m.station))); const merged = [...localMatches]; for (const s of serverStations) { const key = keyOf(s); if (seen.has(key)) continue; seen.add(key); merged.push({ station: s, rank: null, score: 0 }); } return merged.slice(0, 8); } async function searchLocation(query) { const resultsEl = document.getElementById('map-search-results'); const seq = ++_searchSeq; // Local viewport hits are synchronous — show them at once for instant // feedback while the async place/station lookups are still in flight. const localMatches = matchStationsByQuery(query); if (localMatches.length) { resultsEl.innerHTML = renderStationSearchResultsHtml(localMatches); resultsEl.classList.remove('hidden'); attachStationSearchHandlers(resultsEl, localMatches); } const [locationItems, serverStations] = await Promise.all([ fetchPlaceResults(query), fetchServerStationMatches(query), ]); // A newer keystroke already started — discard these stale results. if (seq !== _searchSeq) return; const stationMatches = mergeStationMatches(localMatches, serverStations); _lastSearchResults = { locationItems, stationMatches }; renderSearchResultsInto(resultsEl, locationItems, stationMatches); } // Builds the dropdown markup (places first, stations after) and wires handlers. function renderSearchResultsInto(resultsEl, locationItems, stationMatches) { const stationHtml = renderStationSearchResultsHtml(stationMatches); const locationHtml = renderLocationSearchResultsHtml(locationItems); if (!stationHtml && !locationHtml) { resultsEl.innerHTML = `
${t('noSearchResults')}
`; resultsEl.classList.remove('hidden'); return; } // Places first, stations after — places are the preferred result type. resultsEl.innerHTML = locationHtml + stationHtml; resultsEl.classList.remove('hidden'); attachLocationSearchHandlers(resultsEl); attachStationSearchHandlers(resultsEl, stationMatches); } // Re-render the open search dropdown so station price colours pick up a price // band that loaded after the results were first drawn. Without this the map and // station list recolour on band-ready but the dropdown stays neutral grey. function recolorOpenSearchResults() { const resultsEl = document.getElementById('map-search-results'); if (!resultsEl || resultsEl.classList.contains('hidden') || !_lastSearchResults) return; renderSearchResultsInto(resultsEl, _lastSearchResults.locationItems, _lastSearchResults.stationMatches); } // ─── Route planner ──────────────────────────────────────────────── function formatDuration(min) { if (!Number.isFinite(min) || min < 1) return '<1 min'; const h = Math.floor(min / 60); const m = Math.round(min % 60); if (h === 0) return `${m} min`; if (m === 0) return `${h} h`; return `${h} h ${m} min`; } function setupRoutePanel() { const chip = document.getElementById('map-route-chip'); const panel = document.getElementById('map-route-panel'); if (!chip || !panel || chip._setup) return; chip._setup = true; const startInput = document.getElementById('map-route-start-input'); const destInput = document.getElementById('map-route-dest-input'); const startResults = document.getElementById('map-route-start-results'); const destResults = document.getElementById('map-route-dest-results'); const goBtn = document.getElementById('map-route-go'); const closeBtn = document.getElementById('map-route-close'); const summaryEl = document.getElementById('map-route-summary'); chip.addEventListener('click', () => { haptic('light'); const expanded = chip.getAttribute('aria-expanded') === 'true'; if (expanded) { panel.classList.add('hidden'); chip.setAttribute('aria-expanded', 'false'); } else { panel.classList.remove('hidden'); chip.setAttribute('aria-expanded', 'true'); // If user has GPS, pre-fill start as "current location" sentinel. if (!startInput.value && (state.userLat != null && state.userLng != null)) { startInput.value = t('routeCurrentLocation'); startInput.dataset.lat = String(state.userLat); startInput.dataset.lng = String(state.userLng); updateRouteGoButton(); } destInput.focus(); } }); closeBtn.addEventListener('click', () => { haptic('light'); closeRoutePanel(); if (state.routeMode) exitRouteMode(); }); bindRouteAutocomplete(startInput, startResults, { allowCurrentLocation: true }); bindRouteAutocomplete(destInput, destResults, { allowCurrentLocation: false }); goBtn.addEventListener('click', async () => { const start = getRouteInputCoords(startInput); const dest = getRouteInputCoords(destInput); if (!start) { showToast(t('routeNoStart')); return; } if (!dest) { showToast(t('routeNoDest')); return; } goBtn.disabled = true; summaryEl.classList.remove('hidden'); summaryEl.textContent = t('routeLoading'); try { await enterRouteMode(start, dest); } catch (err) { summaryEl.textContent = err?.message || t('routeNoRoute'); } finally { goBtn.disabled = false; updateRouteGoButton(); } }); [startInput, destInput].forEach(i => i.addEventListener('input', updateRouteGoButton)); function updateRouteGoButton() { const start = getRouteInputCoords(startInput); const dest = getRouteInputCoords(destInput); goBtn.disabled = !(start && dest); } } function getRouteInputCoords(input) { const lat = parseFloat(input.dataset.lat || ''); const lng = parseFloat(input.dataset.lng || ''); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; return { lat, lng, label: input.value }; } function bindRouteAutocomplete(input, resultsEl, { allowCurrentLocation }) { let debounceTimer = null; input.addEventListener('input', () => { // Clear attached coords whenever the user types — they'll be re-set // when the user picks a suggestion. delete input.dataset.lat; delete input.dataset.lng; const q = input.value.trim(); clearTimeout(debounceTimer); if (!q) { resultsEl.classList.add('hidden'); resultsEl.innerHTML = ''; return; } debounceTimer = setTimeout(async () => { try { const resp = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=8&countrycodes=de,at&accept-language=${state.lang || 'de'}`); const data = await resp.json(); renderRouteSuggestions(resultsEl, input, data, { allowCurrentLocation }); } catch { resultsEl.innerHTML = `
${t('noSearchResults')}
`; resultsEl.classList.remove('hidden'); } }, 350); }); input.addEventListener('focus', () => { if (allowCurrentLocation && state.userLat != null && state.userLng != null && !input.value.trim()) { renderRouteSuggestions(resultsEl, input, [], { allowCurrentLocation }); } }); document.addEventListener('click', (e) => { if (!e.target.closest('#map-route-panel')) { resultsEl.classList.add('hidden'); } }); } function renderRouteSuggestions(resultsEl, input, data, { allowCurrentLocation }) { const items = []; if (allowCurrentLocation && state.userLat != null && state.userLng != null) { items.push(`
${t('routeCurrentLocation')}
`); } (data || []).forEach(item => { items.push(`
${escapeHtml(item.display_name)}
`); }); if (!items.length) { resultsEl.innerHTML = `
${t('noSearchResults')}
`; resultsEl.classList.remove('hidden'); return; } resultsEl.innerHTML = items.join(''); resultsEl.classList.remove('hidden'); resultsEl.querySelectorAll('.map-route-suggest-item').forEach(el => { el.addEventListener('click', () => { input.value = el.dataset.label || el.querySelector('span').textContent; input.dataset.lat = el.dataset.lat; input.dataset.lng = el.dataset.lng; resultsEl.classList.add('hidden'); document.getElementById('map-route-go').disabled = !(getRouteInputCoords(document.getElementById('map-route-start-input')) && getRouteInputCoords(document.getElementById('map-route-dest-input'))); }); }); } async function enterRouteMode(start, dest) { const resp = await fetch('/api/route', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ startLat: start.lat, startLng: start.lng, destLat: dest.lat, destLng: dest.lng, fuel: state.fuelType, bufferKm: 3, }), }); if (resp.status === 401) throw new Error(t('routeLoginRequired')); if (resp.status === 503) throw new Error(t('routeNoOrs')); if (!resp.ok) throw new Error(t('routeNoRoute')); const data = await resp.json(); if (!data.route?.coordinates?.length) throw new Error(t('routeNoRoute')); state.routeMode = true; state.routeStart = start; state.routeDest = dest; state.routeSummary = { distanceKm: data.route.distanceKm, durationMin: data.route.durationMin, count: data.stations.length, }; // Clear existing station markers / cluster / user pin so only the route // and its corridor remain visible. if (state.clusterGroup) { state.map.removeLayer(state.clusterGroup); state.clusterGroup = null; } state.markers.forEach(m => state.map.removeLayer(m)); state.markers = []; // Leaflet doesn't resolve CSS vars in SVG attributes — grab the computed value. const accentColor = getComputedStyle(document.documentElement) .getPropertyValue('--color-accent').trim() || '#007aff'; // Polyline — Leaflet takes [lat, lng] but ORS gives [lng, lat]. const latlngs = data.route.coordinates.map(([lng, lat]) => [lat, lng]); if (state.routeLayer) state.map.removeLayer(state.routeLayer); state.routeLayer = L.polyline(latlngs, { color: accentColor, weight: 5, opacity: 0.85, interactive: false, }).addTo(state.map); // Start/dest pin markers if (state.routeStartMarker) state.map.removeLayer(state.routeStartMarker); if (state.routeDestMarker) state.map.removeLayer(state.routeDestMarker); state.routeStartMarker = L.marker([start.lat, start.lng], { icon: L.divIcon({ className: '', html: `
`, iconSize: [14, 14], iconAnchor: [7, 7], }), }).addTo(state.map); state.routeDestMarker = L.marker([dest.lat, dest.lng], { icon: L.divIcon({ className: '', html: `
`, iconSize: [14, 14], iconAnchor: [7, 7], }), }).addTo(state.map); // Overlay stations from the corridor response into state.stations and render. state.stations = data.stations; renderStationsOnMap(data.stations, { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(data.stations); // Fit map to route + both pins. state.map.fitBounds(state.routeLayer.getBounds().pad(0.1)); // Hide competing UI bits — the normal search/scan flow would clobber // state.stations and erase the corridor. document.getElementById('btn-search-here')?.classList.add('hidden'); document.getElementById('map-search-box')?.classList.add('hidden'); document.getElementById('map-loading')?.classList.add('hidden'); // Update panel summary + show exit button. const summaryEl = document.getElementById('map-route-summary'); summaryEl.classList.remove('hidden'); summaryEl.textContent = t('routeSummary')( state.routeSummary.distanceKm, state.routeSummary.durationMin, state.routeSummary.count, ); showRouteExitButton(); // Kick off scan-point fills in the background. The function manages its own // markers, sequential 30 s cooldown, and incremental corridor refresh — we // don't await it because the UI is already interactive with cached results. if (Array.isArray(data.scanPoints) && data.scanPoints.length > 0) { processRouteScanPoints(data.scanPoints, start, dest); } else if (!data.stations.length) { showToast(t('routeNoStations')); } } /** * Walk the scan points returned by /api/route, dropping a yellow dot at each. * Scan them one-by-one with a 30 s cooldown between calls; refresh the * corridor list after each successful fill so the sidebar grows as new * stations come in. */ async function processRouteScanPoints(scanPoints, start, dest) { const runId = ++state.routeScanRunId; const fuel = state.fuelType; // Clean up any previous run's markers before placing new ones. if (state.routeScanMarkers) { state.routeScanMarkers.forEach((entry) => { try { state.map.removeLayer(entry.marker); } catch {} entry.stopRipples?.(); }); } state.routeScanMarkers = scanPoints.map((p) => { const marker = L.marker([p.lat, p.lng], { icon: L.divIcon({ className: 'route-scan-dot pending', html: '
', iconSize: [18, 18], iconAnchor: [9, 9], }), interactive: false, keyboard: false, }).addTo(state.map); // Continuous ripple circles at the 25 km live-scan radius — same // visual as the "Hier suchen" button so the user sees exactly what // area each dot will cover. Cancelled when the dot transitions to // done / empty / error. const stopRipples = startRouteScanRipples(p.lat, p.lng); return { marker, stopRipples, lat: p.lat, lng: p.lng }; }); const setDotState = (i, cls) => { const entry = state.routeScanMarkers?.[i]; if (!entry) return; const el = entry.marker.getElement(); if (el) { el.classList.remove('pending', 'scanning', 'done', 'empty', 'error'); el.classList.add(cls); } if (cls === 'done' || cls === 'empty' || cls === 'error') { entry.stopRipples?.(); entry.stopRipples = null; } }; for (let i = 0; i < scanPoints.length; i++) { if (runId !== state.routeScanRunId || !state.routeMode) return; if (i > 0) { await sleep(30000); if (runId !== state.routeScanRunId || !state.routeMode) return; } setDotState(i, 'scanning'); let scanOk = false; let hadStations = false; let rateLimited = false; try { const resp = await fetch('/api/route/scan-point', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat: scanPoints[i].lat, lng: scanPoints[i].lng, fuel, }), }); const body = await resp.json().catch(() => ({})); if (resp.ok) { scanOk = true; hadStations = (body.stationsFound ?? 0) > 0; } else if (resp.status === 429 || body.rateLimited) { // Tankerkönig soft rate-cap — the 30 s wait before the next scan // usually clears it. Distinguish from a real failure so we don't // scare the user with a red dot for a backend throttling event. rateLimited = true; } } catch { // network error — treated as 'error' dot below } if (runId !== state.routeScanRunId || !state.routeMode) return; const dotState = scanOk ? (hadStations ? 'done' : 'empty') : rateLimited ? 'empty' : 'error'; setDotState(i, dotState); // Refresh corridor list from the now-warmer cache. try { const resp = await fetch('/api/route', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ startLat: start.lat, startLng: start.lng, destLat: dest.lat, destLng: dest.lng, fuel, bufferKm: 3, }), }); if (resp.ok) { const data = await resp.json(); if (runId !== state.routeScanRunId || !state.routeMode) return; if (Array.isArray(data.stations)) { state.stations = data.stations; if (state.routeSummary) state.routeSummary.count = data.stations.length; renderStationsOnMap(data.stations, { skipFitBounds: true, skipRadiusFilter: true }); renderStationList(data.stations); const summaryEl = document.getElementById('map-route-summary'); if (summaryEl && state.routeSummary) { summaryEl.textContent = t('routeSummary')( state.routeSummary.distanceKm, state.routeSummary.durationMin, state.routeSummary.count, ); } } } } catch { // keep going to next scan point — partial results are better than nothing } } // Fade markers out a moment after the last scan so the user sees the final state. setTimeout(() => { if (runId !== state.routeScanRunId) return; state.routeScanMarkers?.forEach((entry) => { try { state.map.removeLayer(entry.marker); } catch {} entry.stopRipples?.(); }); state.routeScanMarkers = null; }, 3000); } /** * Spawn continuously expanding L.circle ripples at (lat, lng) that grow to * the 25 km live-scan radius — identical to the "Hier suchen" animation so * users see exactly what geographic area each dot scans. Zoom changes keep * the ripples at the correct ground size (L.circle uses meters). * Returns a cancel function that stops the interval and removes live ripples. */ const ROUTE_SCAN_RIPPLE_RADIUS_KM = 25; const ROUTE_SCAN_RIPPLE_DURATION = 1800; const ROUTE_SCAN_RIPPLE_STAGGER = 900; function startRouteScanRipples(lat, lng) { let stopped = false; const live = new Set(); const finalRadius = ROUTE_SCAN_RIPPLE_RADIUS_KM * 1000; const color = getAccentColor(); const spawn = () => { if (stopped || !state.map) return; let ripple; try { ripple = L.circle([lat, lng], { radius: 0, color, weight: 2, opacity: 0.8, fillColor: color, fillOpacity: 0.1, interactive: false, }).addTo(state.map); } catch { return; } live.add(ripple); const t0 = performance.now(); const step = (now) => { if (stopped) return; const t = Math.min((now - t0) / ROUTE_SCAN_RIPPLE_DURATION, 1); const eased = 1 - Math.pow(1 - t, 3); try { ripple.setRadius(eased * finalRadius); ripple.setStyle({ opacity: 0.8 * (1 - t * 0.8), fillOpacity: 0.1 * (1 - t), }); } catch { live.delete(ripple); return; } if (t < 1) requestAnimationFrame(step); else { try { state.map.removeLayer(ripple); } catch {} live.delete(ripple); } }; requestAnimationFrame(step); }; spawn(); const interval = setInterval(spawn, ROUTE_SCAN_RIPPLE_STAGGER); return () => { stopped = true; clearInterval(interval); live.forEach((r) => { try { state.map.removeLayer(r); } catch {} }); live.clear(); }; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function exitRouteMode() { if (!state.routeMode) return; state.routeScanRunId++; // cancel in-flight scan loop if (state.routeLayer) { state.map.removeLayer(state.routeLayer); state.routeLayer = null; } if (state.routeStartMarker) { state.map.removeLayer(state.routeStartMarker); state.routeStartMarker = null; } if (state.routeDestMarker) { state.map.removeLayer(state.routeDestMarker); state.routeDestMarker = null; } if (state.routeScanMarkers) { state.routeScanMarkers.forEach((entry) => { try { state.map.removeLayer(entry.marker); } catch {} entry.stopRipples?.(); }); state.routeScanMarkers = null; } state.routeMode = false; state.routeStart = null; state.routeDest = null; state.routeSummary = null; document.getElementById('btn-search-here')?.classList.remove('hidden'); document.getElementById('map-search-box')?.classList.remove('hidden'); hideRouteExitButton(); // Reload normal station list for current viewport. loadStationsAroundCenter({ silent: false }); } function closeRoutePanel() { const chip = document.getElementById('map-route-chip'); const panel = document.getElementById('map-route-panel'); if (!chip || !panel) return; panel.classList.add('hidden'); chip.setAttribute('aria-expanded', 'false'); } function showRouteExitButton() { let btn = document.getElementById('route-exit-btn'); if (!btn) { btn = document.createElement('button'); btn.id = 'route-exit-btn'; btn.className = 'route-exit-btn'; btn.type = 'button'; btn.innerHTML = `${t('routeExit')}`; btn.addEventListener('click', () => { haptic('light'); exitRouteMode(); }); document.getElementById('map-container').appendChild(btn); } else { btn.classList.remove('hidden'); } } function hideRouteExitButton() { const btn = document.getElementById('route-exit-btn'); if (btn) btn.classList.add('hidden'); } // Channel picker visibility. With SMTP unconfigured, ntfy is normally the // only channel and the picker is a decorative single button — hide it and // its label. Exception: a persisted e-mail alert (saved while SMTP was // configured, or synced from another device) must keep the picker visible // so the user can still switch back to ntfy. function updateAlertChannelUi() { const showEmail = state.smtpConfigured || state.alertChannel === 'email'; const emailBtn = document.getElementById('alert-ch-email'); const picker = document.getElementById('alert-channel-picker'); const pickerLabel = document.querySelector('.alert-channel-section .alert-config-label'); if (emailBtn) emailBtn.style.display = showEmail ? '' : 'none'; if (picker) picker.style.display = showEmail ? '' : 'none'; if (pickerLabel) pickerLabel.style.display = showEmail ? '' : 'none'; } function initAlertUI() { document.getElementById('alert-toggle').addEventListener('change', async (e) => { haptic('light'); const toggle = e.target; const config = document.getElementById('alert-config'); if (toggle.checked) { // Just reveal the config; nothing is saved server-side until "Save". config.style.display = 'block'; } else { // Optimistic disable: hide UI immediately, roll back on failure. const previous = config.style.display; config.style.display = 'none'; try { await deleteAlert(); } catch { // Roll back on error toggle.checked = true; config.style.display = previous || 'block'; } } }); document.getElementById('alert-minus').addEventListener('click', () => { haptic('light'); const next = Math.max(1.00, Math.round(((state.alertPrice || 2.0) - 0.01) * 100) / 100); state.alertPrice = next; updateAlertDisplay(); }); document.getElementById('alert-plus').addEventListener('click', () => { haptic('light'); const next = Math.min(3.00, Math.round(((state.alertPrice || 2.0) + 0.01) * 100) / 100); state.alertPrice = next; updateAlertDisplay(); }); // Direct numeric entry — user can type the threshold instead of stepping. const priceInput = document.getElementById('alert-price-display'); if (priceInput) { const commit = () => { const raw = (priceInput.value || '').replace(/[€\s]/g, '').replace(',', '.'); const n = parseFloat(raw); if (Number.isFinite(n)) { state.alertPrice = Math.max(1.00, Math.min(3.00, Math.round(n * 100) / 100)); } updateAlertDisplay(); }; priceInput.addEventListener('focus', () => { // Strip the € while editing so the user types a clean number. priceInput.value = (state.alertPrice || 2.0).toFixed(2).replace('.', ','); requestAnimationFrame(() => priceInput.select()); }); priceInput.addEventListener('blur', commit); priceInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); priceInput.blur(); } else if (e.key === 'Escape') { e.preventDefault(); priceInput.blur(); } }); } // Draggable threshold bar — click or drag the marker to set the price. const bar = document.getElementById('alert-threshold-bar'); const track = bar?.querySelector('.alert-threshold-bar-track'); if (bar && track) { const priceFromX = (clientX) => { const lowText = document.getElementById('alert-bar-low')?.textContent || ''; const highText = document.getElementById('alert-bar-high')?.textContent || ''; const low = parseFloat(lowText.replace(',', '.')); const high = parseFloat(highText.replace(',', '.')); if (!Number.isFinite(low) || !Number.isFinite(high) || high <= low) return null; const rect = track.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.round((low + pct * (high - low)) * 100) / 100; }; const apply = (clientX) => { const price = priceFromX(clientX); if (price == null) return; state.alertPrice = Math.max(1.00, Math.min(3.00, price)); updateAlertDisplay(); }; let dragging = false; bar.addEventListener('pointerdown', (e) => { if (bar.hidden) return; e.preventDefault(); dragging = true; bar.classList.add('is-dragging'); try { bar.setPointerCapture(e.pointerId); } catch {} haptic('light'); apply(e.clientX); }); bar.addEventListener('pointermove', (e) => { if (!dragging) return; apply(e.clientX); }); const endDrag = (e) => { if (!dragging) return; dragging = false; bar.classList.remove('is-dragging'); try { bar.releasePointerCapture(e.pointerId); } catch {} }; bar.addEventListener('pointerup', endDrag); bar.addEventListener('pointercancel', endDrag); bar.addEventListener('keydown', (e) => { if (bar.hidden) return; const cur = state.alertPrice || 2.0; let next = null; if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = cur - 0.01; else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = cur + 0.01; else if (e.key === 'PageDown') next = cur - 0.05; else if (e.key === 'PageUp') next = cur + 0.05; if (next != null) { e.preventDefault(); state.alertPrice = Math.max(1.00, Math.min(3.00, Math.round(next * 100) / 100)); updateAlertDisplay(); } }); } document.getElementById('alert-save').addEventListener('click', saveAlert); document.getElementById('alert-test').addEventListener('click', testAlert); updateAlertChannelUi(); // Channel picker const channelSegs = document.querySelectorAll('#alert-channel-picker .alert-ch-seg'); channelSegs.forEach(seg => { seg.addEventListener('click', () => { haptic('light'); const ch = seg.dataset.channel; channelSegs.forEach(s => { s.classList.remove('active'); s.setAttribute('aria-selected', 'false'); }); seg.classList.add('active'); seg.setAttribute('aria-selected', 'true'); state.alertChannel = ch; document.getElementById('alert-ntfy-config').style.display = ch === 'ntfy' ? 'block' : 'none'; document.getElementById('alert-email-config').style.display = ch === 'email' ? 'block' : 'none'; }); }); // ntfy topic input const ntfyInput = document.getElementById('alert-ntfy-topic'); if (ntfyInput) { ntfyInput.addEventListener('input', () => { state.alertNtfyTopic = ntfyInput.value.trim(); }); } // email input const emailInput = document.getElementById('alert-email-address'); if (emailInput) { emailInput.addEventListener('input', () => { state.alertEmail = emailInput.value.trim(); }); } refreshAlertUi(); } // Format a relative timestamp like "vor 3 Min." / "vor 2 Std." for the // alert status panel. Uses the same i18n keys as the rest of the app. function formatRelativeAgo(iso) { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; const diffMs = Date.now() - d.getTime(); if (diffMs < 60_000) return t('alertJustNow') || 'just now'; const mins = Math.round(diffMs / 60_000); if (mins < 60) return (t('alertMinutesAgoFmt') || '{n} min ago').replace('{n}', mins); const hours = Math.round(mins / 60); if (hours < 36) return (t('alertHoursAgoFmt') || '{n} h ago').replace('{n}', hours); const days = Math.round(hours / 24); return (t('alertDaysAgoFmt') || '{n} d ago').replace('{n}', days); } async function refreshAlertUi() { const toggle = document.getElementById('alert-toggle'); const configEl = document.getElementById('alert-config'); const activeInfo = document.getElementById('alert-active-info'); const refEl = document.getElementById('alert-ref-price'); const card = document.getElementById('alert-card'); // Track the cheapest in radius so the threshold bar + status panel can // show how the user's threshold relates to the current market. let cheapestInRadius = null; try { // Reuse the stations the map already loaded instead of re-fetching on // every settings-tab visit — the threshold bar only needs a rough // "cheapest nearby" anchor, and the map data is at most minutes old. let stations = Array.isArray(state.stations) && state.stations.length ? state.stations : null; if (!stations) { const coords = getActiveCoords(); stations = await api(`/api/stations?lat=${coords.lat}&lng=${coords.lng}&rad=${state.radiusKm}&fuel=${state.fuelType}`); } const priced = stations.filter(s => typeof s.price === 'number'); if (priced.length) { const prices = priced.map(s => s.price); const min = Math.min(...prices); const max = Math.max(...prices); cheapestInRadius = { min, max }; refEl.textContent = (t('alertStatusCheapestFmt') || 'Currently cheapest price: {price}').replace('{price}', formatPrice(min)); if (!state.alertPrice) state.alertPrice = Math.max(1.5, Math.round((min - 0.03) * 100) / 100); } } catch {} let alert = null; try { alert = await api(state.user ? '/api/alert' : '/api/alert/local'); } catch { /* network error — keep current UI */ return; } if (alert?.threshold && alert.enabled !== false) { state.alertPrice = alert.threshold; state.alertChannel = alert.channel || 'ntfy'; state.alertNtfyTopic = alert.ntfyTopic || ''; state.alertEmail = alert.email || ''; toggle.checked = true; configEl.style.display = 'block'; if (card) card.classList.add('is-armed'); activeInfo.style.display = 'none'; // status panel replaces this activeInfo.textContent = ''; // Restore channel picker UI (and re-evaluate whether the picker is // needed — a synced e-mail alert must surface it even without SMTP) updateAlertChannelUi(); const channelSegs = document.querySelectorAll('#alert-channel-picker .alert-ch-seg'); channelSegs.forEach(s => { const active = s.dataset.channel === state.alertChannel; s.classList.toggle('active', active); s.setAttribute('aria-selected', active ? 'true' : 'false'); }); document.getElementById('alert-ntfy-config').style.display = state.alertChannel === 'ntfy' ? 'block' : 'none'; document.getElementById('alert-email-config').style.display = state.alertChannel === 'email' ? 'block' : 'none'; const ntfyInput = document.getElementById('alert-ntfy-topic'); if (ntfyInput) ntfyInput.value = state.alertNtfyTopic; const emailInput = document.getElementById('alert-email-address'); if (emailInput) emailInput.value = state.alertEmail; // Status panel — surfaces the background evaluator's state so the // user understands the alert is being checked server-side, not // only when the website is open. const statusLabel = document.getElementById('alert-status-label'); const statusState = document.getElementById('alert-status-state'); const statusPanel = document.getElementById('alert-status-panel'); const lastRow = document.getElementById('alert-status-last'); const lastText = document.getElementById('alert-status-last-text'); if (statusLabel && statusState) { const isBelow = cheapestInRadius && cheapestInRadius.min < alert.threshold; statusState.classList.toggle('is-below', !!isBelow); statusState.classList.toggle('is-armed', !isBelow); if (statusPanel) { statusPanel.classList.toggle('is-below', !!isBelow); statusPanel.classList.toggle('is-armed', !isBelow); } statusLabel.textContent = isBelow ? t('alertStatusBelow') || 'Currently below threshold' : t('alertStatusArmed') || 'Active · monitoring in the background'; } if (lastRow && lastText) { if (alert.lastNotifiedAt) { const when = formatRelativeAgo(alert.lastNotifiedAt); const priceStr = typeof alert.lastNotifiedPrice === 'number' ? formatPrice(alert.lastNotifiedPrice) : ''; lastRow.hidden = false; lastText.textContent = (t('alertStatusLastFiredFmt') || 'Last notified {when} at {price}') .replace('{when}', when) .replace('{price}', priceStr); } else { lastRow.hidden = true; } } } else { // No active alert — collapse the panel. toggle.checked = false; configEl.style.display = 'none'; if (card) card.classList.remove('is-armed'); activeInfo.style.display = 'none'; activeInfo.textContent = ''; // Reset the status row to the neutral "not yet active" look so the // green dot doesn't lie if the user re-opens the panel without saving. const statusState = document.getElementById('alert-status-state'); const statusPanel = document.getElementById('alert-status-panel'); const statusLabel = document.getElementById('alert-status-label'); const lastRow = document.getElementById('alert-status-last'); statusState?.classList.remove('is-armed', 'is-below'); statusPanel?.classList.remove('is-armed', 'is-below'); if (statusLabel) statusLabel.textContent = t('alertStatusInactive') || 'Noch nicht aktiv'; if (lastRow) lastRow.hidden = true; // No alert to preserve — if the server can't send e-mail, fall back // to ntfy so a fresh alert starts on a channel that actually works. if (!state.smtpConfigured && state.alertChannel === 'email') { state.alertChannel = 'ntfy'; document.querySelectorAll('#alert-channel-picker .alert-ch-seg').forEach(s => { const active = s.dataset.channel === 'ntfy'; s.classList.toggle('active', active); s.setAttribute('aria-selected', active ? 'true' : 'false'); }); const ntfyCfg = document.getElementById('alert-ntfy-config'); const emailCfg = document.getElementById('alert-email-config'); if (ntfyCfg) ntfyCfg.style.display = 'block'; if (emailCfg) emailCfg.style.display = 'none'; } updateAlertChannelUi(); } // Threshold-vs-current-cheapest bar. The threshold marker slides along // a track bounded by [cheapest-0.10, cheapest+0.15] so the user can // see whether their threshold is realistic without having to do mental // maths against the cheapest price. const barEl = document.getElementById('alert-threshold-bar'); const barCurrent = document.getElementById('alert-bar-current'); const barMarker = document.getElementById('alert-bar-marker'); const barLow = document.getElementById('alert-bar-low'); const barHigh = document.getElementById('alert-bar-high'); if (barEl && cheapestInRadius && state.alertPrice) { const low = Math.max(1.0, cheapestInRadius.min - 0.10); const high = cheapestInRadius.min + 0.15; const range = Math.max(high - low, 0.0001); const clamp01 = (n) => Math.max(0, Math.min(1, n)); const currentPct = clamp01((cheapestInRadius.min - low) / range) * 100; const markerPct = clamp01((state.alertPrice - low) / range) * 100; barEl.hidden = false; if (barCurrent) barCurrent.style.left = `${currentPct}%`; if (barMarker) barMarker.style.left = `${markerPct}%`; if (barLow) barLow.textContent = formatPrice(low); if (barHigh) barHigh.textContent = formatPrice(high); } else if (barEl) { barEl.hidden = true; } updateAlertDisplay(); } function updateAlertDisplay() { const display = document.getElementById('alert-price-display'); if (display && document.activeElement !== display) { display.value = formatPrice(state.alertPrice || 2.0); } // Slide the threshold marker on the visual bar as the user steps the // price. Keep the marker inside the visible track. const barEl = document.getElementById('alert-threshold-bar'); const marker = document.getElementById('alert-bar-marker'); if (barEl && !barEl.hidden && marker) { const lowText = document.getElementById('alert-bar-low')?.textContent || ''; const highText = document.getElementById('alert-bar-high')?.textContent || ''; const low = parseFloat(lowText.replace(',', '.')); const high = parseFloat(highText.replace(',', '.')); if (Number.isFinite(low) && Number.isFinite(high) && high > low) { const pct = Math.max(0, Math.min(1, ((state.alertPrice || 0) - low) / (high - low))); marker.style.left = `${pct * 100}%`; } barEl.setAttribute('aria-valuenow', String((state.alertPrice || 0).toFixed(2))); } } async function saveAlert() { haptic('medium'); if (state.alertChannel === 'ntfy' && !state.alertNtfyTopic) { showToast(t('enterNtfyTopic')); return; } if (state.alertChannel === 'email' && !state.alertEmail) { showToast(t('enterEmail')); return; } if (state.user) setSyncBadgeState('syncing', ['alert']); try { // Capture the user's current location so the background evaluator // knows which area to watch. Falls back to the GPS / saved fallback. const coords = getActiveCoords(); const result = await api(state.user ? '/api/alert' : '/api/alert/local', { method: 'POST', body: JSON.stringify({ threshold: state.alertPrice, fuel: state.fuelType, enabled: true, channel: state.alertChannel, ntfyTopic: state.alertNtfyTopic, email: state.alertEmail, lat: coords.lat, lng: coords.lng, radiusKm: state.radiusKm || 25, }) }); if (result?.ok) { haptic('success'); if (state.user) setSyncBadgeState('synced', ['alert']); const chLabel = state.alertChannel === 'email' ? ' (E-Mail)' : ' (ntfy.sh)'; document.getElementById('alert-active-info').style.display = 'block'; document.getElementById('alert-active-info').textContent = `${t('alertActive')} ${formatPrice(state.alertPrice)}${chLabel}`; showPopup(t('saved'), `${t('alertSetMsg')} ${state.fuelType.toUpperCase()} ${t('under')} ${formatPrice(state.alertPrice)}.`); await refreshMe(); // Pull the saved alert back so the status row flips from // "Noch nicht aktiv" to the green "Aktiv" state without a page reload. await refreshAlertUi(); } } catch (error) { if (state.user) setSyncBadgeState('idle', ['alert']); showPopup(t('alertFailed'), error.message || t('serverError')); } } async function testAlert() { haptic('light'); const title = t('testAlertTitle'); const body = t('testAlertBody'); if (state.alertChannel === 'email') { if (!state.alertEmail) { showToast(t('enterEmail')); return; } try { await api('/api/alert/email', { method: 'POST', body: JSON.stringify({ to: state.alertEmail, subject: title, body }) }); haptic('success'); showToast(t('testSentEmail')); } catch (e) { showPopup(t('testFailed'), e.message || t('emailSendFailed')); } } else { if (!state.alertNtfyTopic) { showToast(t('enterNtfyTopic')); return; } try { await api('/api/alert/notify', { method: 'POST', body: JSON.stringify({ topic: state.alertNtfyTopic, title, message: body }) }); haptic('success'); showToast(t('testSentNtfy')); } catch (e) { showPopup(t('testFailed'), e.message || t('ntfySendFailed')); } } } // Client-side trigger is no longer needed — the server-side evaluator runs // after every scheduler cycle and handles dedup via lastNotifiedAt / // lastNotifiedPrice. Kept as a no-op so call sites don't need to change. async function checkPriceAlert(_stations) { /* handled server-side */ } async function deleteAlert() { if (state.user) setSyncBadgeState('syncing', ['alert']); try { await api(state.user ? '/api/alert' : '/api/alert/local', { method: 'DELETE' }); document.getElementById('alert-config').style.display = 'none'; document.getElementById('alert-active-info').style.display = 'none'; const card = document.getElementById('alert-card'); if (card) card.classList.remove('is-armed'); haptic('success'); if (state.user) { setSyncBadgeState('synced', ['alert']); // Keep state.user.alerts in sync with the server so any later reader // (or another refreshAlertUi pass) doesn't see the just-deleted alert. await refreshMe(); } } catch (error) { if (state.user) setSyncBadgeState('idle', ['alert']); showPopup(t('deleteFailed'), error.message || t('deleteError')); throw error; } } function setupMyLocationBtn() { const locBtn = document.getElementById('btn-my-location'); locBtn.addEventListener('pointerdown', e => e.stopPropagation()); locBtn.addEventListener('click', (e) => { e.stopPropagation(); haptic('medium'); const flyToUser = (lat, lng) => { if (!state.map) return; // Toggle: if already centered on user, zoom out to show all stations const center = state.map.getCenter(); const isNearUser = Math.abs(center.lat - lat) < 0.002 && Math.abs(center.lng - lng) < 0.002 && state.map.getZoom() >= 13; if (isNearUser && state.defaultBounds) { state.map.flyToBounds(state.defaultBounds, { duration: 0.5 }); } else { state.map.flyTo([lat, lng], 14, { duration: 0.5 }); } }; // Always try device GPS first, fall back to last known location if (navigator.geolocation) { locBtn.style.opacity = '0.5'; navigator.geolocation.getCurrentPosition( pos => { state.userLat = pos.coords.latitude; state.userLng = pos.coords.longitude; locBtn.style.opacity = '1'; state.activeLocation = 'gps'; document.getElementById('map-location-banner')?.classList.add('hidden'); persistStateSettings({ activeLocation: state.activeLocation }); state.loaded.map = false; loadMapTab({ skipFitBounds: true, silent: true }).then(() => flyToUser(state.userLat, state.userLng)); }, () => { locBtn.style.opacity = '1'; // GPS unavailable — fall back to last known coordinates (e.g. search result) if (state.userLat && state.userLng) { flyToUser(state.userLat, state.userLng); } else { showPopup(t('locationTitle'), t('locationError')); } }, { timeout: 10000, maximumAge: 60000 } ); } else if (state.userLat && state.userLng) { // No geolocation API — fall back to last known coordinates flyToUser(state.userLat, state.userLng); } else { showPopup(t('locationTitle'), t('locationError')); } }); } function setupFavouritesToggle() { const btn = document.getElementById('station-fav-toggle'); if (!btn || btn._setup) return; btn._setup = true; applyFavouritesToggleUi(); btn.addEventListener('click', () => { haptic('light'); const next = !state.favouritesOnTop; // persistStateSettings calls applySettingsToState which sets the flag, // refreshes the toggle UI and re-renders the list, plus syncs to the // Felo ID account if logged in. persistStateSettings({ favouritesOnTop: next }); }); } function applyFavouritesToggleUi() { const btn = document.getElementById('station-fav-toggle'); if (!btn) return; const active = !!state.favouritesOnTop; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); } function setupFavouritesOnlyToggle() { const btn = document.getElementById('station-fav-filter'); if (!btn || btn._setup) return; btn._setup = true; applyFavouritesOnlyToggleUi(); btn.addEventListener('click', () => { if (!state.user) { showToast(t('loginRequiredFavourite')); return; } haptic('light'); persistStateSettings({ favouritesOnly: !state.favouritesOnly }); // The list re-renders via applySettingsToState; the markers need an // explicit pass so the map doesn't contradict the filtered list. if (state.map && state.stations?.length) { renderStationsOnMap(state.stations, { skipFitBounds: true, skipRadiusFilter: true }); } }); } function applyFavouritesOnlyToggleUi() { const btn = document.getElementById('station-fav-filter'); if (!btn) return; const active = !!(state.favouritesOnly && state.user); btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); } function setupGroupByPriceToggle() { const btn = document.getElementById('station-group-toggle'); if (!btn || btn._setup) return; btn._setup = true; applyGroupByPriceToggleUi(); btn.addEventListener('click', () => { haptic('light'); // persistStateSettings calls applySettingsToState (which refreshes the // toggle UI and re-renders the list) and syncs the flag to Felo ID when // the user is logged in. persistStateSettings({ groupByPrice: !state.groupByPrice }); }); } function applyGroupByPriceToggleUi() { const btn = document.getElementById('station-group-toggle'); if (!btn) return; const active = !!state.groupByPrice; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); } function setupMapZoomGesture() { const mapEl = document.getElementById('map'); if (!mapEl || mapEl._zoomGestureSetup) return; mapEl._zoomGestureSetup = true; let lastTapTime = 0; let lastTapX = 0; let lastTapY = 0; let holdTimer = null; let holdActive = false; let holdStartY = 0; let holdStartZoom = 0; let holdPoint = null; let rafPending = false; let pendingZoom = null; mapEl.addEventListener('touchstart', (e) => { if (!state.map || e.touches.length !== 1) return; const t = e.touches[0]; const now = Date.now(); const dt = now - lastTapTime; const dx = Math.abs(t.clientX - lastTapX); const dy = Math.abs(t.clientY - lastTapY); if (dt < 320 && dx < 32 && dy < 32) { holdStartY = t.clientY; holdStartZoom = state.map.getZoom(); holdPoint = state.map.mouseEventToContainerPoint({ clientX: t.clientX, clientY: t.clientY }); clearTimeout(holdTimer); holdTimer = setTimeout(() => { holdActive = true; haptic('light'); try { state.map.dragging.disable(); } catch {} try { state.map.touchZoom.disable(); } catch {} try { state.map.doubleClickZoom.disable(); } catch {} }, 180); } }, { passive: true }); mapEl.addEventListener('touchmove', (e) => { if (!state.map || !holdActive || e.touches.length !== 1) return; const t = e.touches[0]; const deltaY = holdStartY - t.clientY; const zoomDelta = deltaY / 140; pendingZoom = Math.max(3, Math.min(19, holdStartZoom + zoomDelta)); if (!rafPending) { rafPending = true; requestAnimationFrame(() => { if (pendingZoom != null) state.map.setZoomAround(holdPoint || state.map.getCenter(), pendingZoom); rafPending = false; }); } e.preventDefault(); e.stopPropagation(); }, { passive: false }); mapEl.addEventListener('touchend', (e) => { if (!state.map) return; clearTimeout(holdTimer); const wasHold = holdActive; holdActive = false; try { state.map.dragging.enable(); } catch {} try { state.map.touchZoom.enable(); } catch {} try { state.map.doubleClickZoom.enable(); } catch {} const t = e.changedTouches[0]; const now = Date.now(); const dx = Math.abs((t?.clientX || 0) - lastTapX); const dy = Math.abs((t?.clientY || 0) - lastTapY); if (!wasHold && now - lastTapTime < 320 && dx < 32 && dy < 32) { const pt = state.map.mouseEventToContainerPoint({ clientX: t.clientX, clientY: t.clientY }); state.map.setZoomAround(pt, state.map.getZoom() + 1); haptic('light'); lastTapTime = 0; e.preventDefault(); return; } lastTapTime = now; lastTapX = t?.clientX || 0; lastTapY = t?.clientY || 0; if (wasHold) { e.preventDefault(); e.stopPropagation(); } }, { passive: false }); } function showResetViewBtn() { const old = document.getElementById('reset-view-btn'); if (old) old.remove(); const btn = document.createElement('button'); btn.id = 'reset-view-btn'; btn.textContent = t('showAll'); btn.style.cssText = 'position:absolute;bottom:12px;left:50%;transform:translateX(-50%);z-index:500;background:var(--color-bg);color:var(--color-text);border:none;border-radius:20px;padding:8px 16px;font-size:13px;font-weight:600;box-shadow:0 2px 12px rgba(0,0,0,0.25);cursor:pointer;display:flex;align-items:center;gap:6px;'; btn.addEventListener('click', () => { haptic('light'); if (state.defaultBounds) state.map.flyToBounds(state.defaultBounds, { duration: 0.5 }); btn.remove(); }); document.getElementById('map-container').appendChild(btn); setTimeout(() => { if (btn.parentNode) btn.remove(); }, 5000); } async function loadSettings() { loadFromLocal(); if (state.user?.settings) { // Theme is device-local only — don't let server override it const { theme, ...remoteSettings } = state.user.settings; applySettingsToState(remoteSettings); } } function loadFromLocal() { try { const val = localStorage.getItem(localSettingsKey); if (val) applySettingsToState(JSON.parse(val)); } catch {} } function applySettingsToState(settings = {}) { if (settings.fuelType) state.fuelType = settings.fuelType; if (settings.activeLocation) state.activeLocation = settings.activeLocation; if (settings.theme) state.theme = settings.theme; if (settings.lang) state.lang = settings.lang; if (typeof settings.contributorsOpen === 'boolean') state.contributorsOpen = settings.contributorsOpen; if (settings.historyDefaultDays === 1 || settings.historyDefaultDays === 7) { state.historyDefaultDays = settings.historyDefaultDays; } if (typeof settings.favouritesOnTop === 'boolean') { state.favouritesOnTop = settings.favouritesOnTop; } if (typeof settings.favouritesOnly === 'boolean') { state.favouritesOnly = settings.favouritesOnly; } if (typeof settings.groupByPrice === 'boolean') { state.groupByPrice = settings.groupByPrice; } state.radiusKm = 25; applyTheme(state.theme); // Theme and history-default ranges are segmented controls now — they // pick up their initial value from state inside their own setup // functions. The language picker stays a