PageSourceSearch

https://www.gas-tankstellen.de/mobile/

html gas-tankstellen.de collected 2026-10-02 10:32:58 UTC 236,359 bytes, 4,567 lines download raw bytes

1<!DOCTYPE html>
2<html lang="de">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6<meta name="mobile-web-app-capable" content="yes">
7<meta name="apple-mobile-web-app-capable" content="yes">
8<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9<meta name="apple-mobile-web-app-title" content="GasTankstellen">
10<meta name="theme-color" content="#1D4CA3">
11<link rel="manifest" href="manifest.json">
12<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
13<title>GasTankstellen</title>
14
15<script src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js?safari-cache-fix=1786264102" crossorigin></script>
vendor: 1 bytes, line 15
15
16<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js?safari-cache-fix=1786264102" crossorigin></script>
16
17
18
19<style>
20*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
22:root {
23  --blue:    #1D4CA3;
24  --blue2:   #163a80;
25  --green:   #27ae60;
26  --red:     #e74c3c;
27  --orange:  #e67e22;
28  --bg:      #f4f6fb;
29  --card:    #ffffff;
30  --text:    #1a1a2e;
31  --sub:     #6b7280;
32  --border:  #e5e7eb;
33  --safe-t:  env(safe-area-inset-top, 0px);
34  --safe-b:  env(safe-area-inset-bottom, 0px);
35  color-scheme: light;
36}
37
38/* 27.07.26 User-Wunsch: Dark-Mode fuer die gesamte PWA, per Profil-Umschalter Hell/Dunkel/Auto
39   (siehe applyEffectiveTheme() im Script). "Auto" berechnet in JS aus der Systemeinstellung und
40   setzt data-theme genauso wie eine explizite Wahl - die CSS-Seite kennt daher nur noch das
41   Attribut, keine @media-Abfrage mehr (vermeidet Spezifitaets-Zickzack zwischen Media-Query und
42   manueller Wahl). Die Kern-Tokens (--bg/--card/--text/--sub/--border) werden hier umdefiniert -
43   alle Komponenten, die bereits darauf statt auf hartcodierte Farben setzen, passen sich
44   automatisch an. Blau/Gruen/Rot/Orange (Marken-/Statusfarben) bleiben bewusst gleich, wie bei
45   den meisten Apps mit farbiger App-Bar. Zusaetzlich color-scheme:dark, damit native
46   Formularelemente (Select, Scrollbars) ebenfalls dunkel rendern. */
47:root[data-theme="dark"] {
48  --bg:     #12151c;
49  --card:   #1c212b;
50  --text:   #eef1f6;
51  --sub:    #9aa3b2;
52  --border: #2a3140;
53  color-scheme: dark;
54}
55
56html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
57/* 26.08.2026 User-Wunsch: "Zoomen per Pinch-Geste ausserhalb der Karte verhindern" (Nachfrage:
58   "auch in der Liste") - versehentliches Pinch auf Listen/Buttons zoomt sonst die ganze Seite
59   (browsertypisches Pinch-Zoom), was sich bei einer PWA wie ein Bug anfuehlt.
60   touch-action:pan-x/pan-y unterbindet Pinch-Zoom + Doppeltipp-Zoom, ohne normales Scrollen zu
61   blockieren. WICHTIG: touch-action vererbt sich in CSS NICHT automatisch an Kind-Elemente -
62   eine Regel nur auf html/body wirkt daher NUR auf diese beiden Elemente selbst, nicht auf den
63   Inhalt darin (per getComputedStyle() live nachgewiesen: #view-list blieb trotzdem "auto").
64   Fix: "*"-Selektor erzwingt touch-action:inherit auf jedem Element, wodurch sich der Wert von
65   html tatsaechlich die ganze Kette hinunter durchreicht. Die drei MapKit-Container brauchen
66   dagegen ihre eigene Pinch-Geste (MapKit JS handhabt Zoom selbst per Touch-Events) - dort
67   touch-action:auto, damit der Browser nichts davon abfaengt. */
68html { touch-action: pan-x pan-y; }
69* { touch-action: inherit; }
70#full-map, #detail-map, #route-map-container { touch-action: auto; }
71
72/* ── Layout ── */
73/* 11.08.2026 User-Report: "als 'zum Homescreen' nimmt die PWA sich nicht den ganzen Raum, erst
74   nach einem Pull-to-Refresh" (hellblauer Rand unten = html/body-Hintergrund schimmert durch,
75   genau in der Hoehe von Safaris Tableiste - User-Beobachtung). Vermutlich ein WebKit-Bug:
76   die Standalone-PWA rendert den allerersten Frame nach Kaltstart noch mit dem Platzverbrauch
77   einer normalen Safari-Tableiste, bevor iOS auf den echten Vollbild-Viewport umschaltet - der
78   von iOS gemeldete sichtbare Bereich ist in diesem Moment selbst falsch, nicht nur die
79   CSS-Berechnung. Versuch mit position:fixed+inset:0 (anstatt 100dvh) behob den Rand NICHT
80   (der falsche Viewport-Wert steckt tiefer), brach aber die Pull-to-Refresh-Geste (User: "PTR
81   geht nicht mehr!") - deshalb zurueckgerollt auf 100dvh. Verbleibt vorerst als bekannte,
82   vermutlich nur durch Apple selbst loesbare iOS-Eigenheit; siehe window.scrollTo()-Nudge im
83   Script als schwacher Mitigationsversuch. */
84#app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow-x: hidden; }
85
86/* ── Pull-to-Refresh (03.08.26) ── */
87.ptr-indicator {
88  position: fixed; left: 50%; top: 0; width: 34px; height: 34px; margin-left: -17px;
89  transform: translateY(-40px); border-radius: 50%; background: var(--card);
90  box-shadow: 0 2px 8px rgba(0,0,0,.2); display: flex; align-items: center; justify-content: center;
91  z-index: 500; transition: transform .2s ease, opacity .2s ease; opacity: 0; pointer-events: none;
92}
93.ptr-indicator.ptr-visible { opacity: 1; }
94.ptr-indicator svg { width: 18px; height: 18px; color: var(--blue); }
95.ptr-indicator.ptr-spin svg { animation: ptr-rotate .7s linear infinite; }
96@keyframes ptr-rotate { to { transform: rotate(360deg); } }
97
98/* ── Header ── */
99#header {
100  background: var(--blue);
101  color: #fff;
102  padding: 12px 16px 12px;
103  display: flex; align-items: center; gap: 10px;
104  box-shadow: 0 2px 8px rgba(0,0,0,.25);
105  flex-shrink: 0;
106  z-index: 10;
107}
108@media (display-mode: standalone) {
109  #header { padding-top: calc(env(safe-area-inset-top, 0px) + 12px); }
110}
111#header h1 { font-size: 1.1rem; font-weight: 700; flex: 1; }
112#header svg { flex-shrink: 0; }
113
114/* ── Suche ── */
115#search-bar {
116  background: var(--blue2);
117  padding: 10px 16px 12px;
118  display: flex; gap: 8px; align-items: center;
119  flex-shrink: 0;
120}
121#search-input {
122  flex: 1; min-width: 0; border: none; border-radius: 10px;
123  padding: 10px 14px; font-size: 1rem;
124  background: rgba(255,255,255,.15); color: #fff;
125  outline: none;
126}
127#search-input::placeholder { color: rgba(255,255,255,.6); }
128#search-input:focus { background: rgba(255,255,255,.25); }
129#btn-search {
130  background: #fff; color: var(--blue);
131  border: none; border-radius: 10px;
132  padding: 10px 14px; font-size: .9rem; font-weight: 700;
133  cursor: pointer; white-space: nowrap;
134}
135
136/* 18.09.2026 (User: Filter auch in der normalen PWA-Suche) - Filter-Button in der Suchleiste, Panel
137   klappt darunter auf; gleiche Auswahl gilt fuer Ort-/Kennzeichen-/Autobahn-/GPS-Suche. */
138#btn-search-filter {
139  background: rgba(255,255,255,.15); color: #fff; border: none; border-radius: 10px;
140  padding: 10px 12px; font-size: .9rem; font-weight: 700; cursor: pointer; white-space: nowrap;
141}
142#btn-search-filter.active { background: #f59e0b; }
143#search-filter-panel {
144  display: none; background: var(--blue2); padding: 0 16px 12px; color: #fff; flex-shrink: 0;
145  grid-template-columns: 1fr 1fr; gap: 4px 12px;
146}
147.sf-row { display: flex; align-items: center; gap: 8px; font-size: .85rem; padding: 4px 0; }
148.sf-row input { width: 18px; height: 18px; }
149@media (max-width: 360px) {
150  #btn-search { padding: 10px 10px; }
151  #btn-search .btn-search-label { display: none; }
152}
153/* ── Gasart-Umschalter (Kopfzeile) ── */
154/* 11.09.2026 (User-Wunsch: "rechts oben [...] 'LPG * CNG' [...] genau DAS als Umschalter [...]
155   Platz sparen. Die aktive Gasart soll grösser und grün angezeigt werden") - vormals eine volle
156   Zeile unter der Suchleiste (grosse Pillen-Buttons), jetzt kompakt in der Kopfzeile. Dieselbe
157   #mode-bar/.mode-btn-Struktur wie zuvor (nur restyled) - keine JS-Aenderung noetig.
158   11.09.2026 Folge-Feedback: aktive Gasart noch groesser, inaktive Gasart hellrot statt grau,
159   mittig ein optischer Schalter (Track+Thumb) statt des reinen "·"-Trenners - rein CSS/Markup,
160   Thumb-Position folgt via :has() automatisch der bestehenden .active-Klasse, keine JS-Aenderung. */
161#mode-bar { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
162.mode-btn {
163  border: none; background: none; color: #ff8a80;
164  padding: 0; font-size: .8rem; font-weight: 600; cursor: pointer;
165  transition: all .15s;
166}
167.mode-btn.active { color: var(--green); font-size: 1.25rem; font-weight: 800; }
168
169#mode-switch {
170  position: relative; display: inline-block; flex-shrink: 0;
171  width: 22px; height: 12px; border-radius: 6px; background: rgba(255,255,255,.25);
172}
173#mode-switch-thumb {
174  position: absolute; top: 1px; left: 1px; width: 10px; height: 10px; border-radius: 50%;
175  background: var(--green); transition: transform .2s;
176}
177#mode-bar:has(.mode-btn[data-mode="cng"].active) #mode-switch-thumb { transform: translateX(10px); }
178
179/* ── GPS Button ── */
180#btn-gps {
181  position: absolute; /* wird per JS gesetzt */
182  margin: 0 auto;
183  display: flex; align-items: center; gap: 8px;
184  background: var(--blue); color: #fff;
185  border: none; border-radius: 50px; padding: 14px 24px;
186  font-size: 1rem; font-weight: 700; cursor: pointer;
187  box-shadow: 0 4px 16px rgba(29,76,163,.4);
188  transition: background .15s;
189}
190#btn-gps:active { background: var(--blue2); }
191/* 18.07.26: Radius-Selektor neben "Meinen Standort nutzen" (User-Wunsch) */
192#gps-radius {
193  border: 1.5px solid var(--blue); border-radius: 50px; padding: 10px 14px;
194  font-size: .95rem; font-weight: 700; color: var(--blue); background: #fff;
195  cursor: pointer;
196}
197
198/* ── Views ── */
199#view-home, #view-list, #view-detail, #view-map {
200  flex: 1; overflow-y: auto; overflow-x: hidden; display: none; flex-direction: column;
201}
202#view-home { display: flex; flex-direction: column; align-items: center; }
203#home-center { text-align: center; padding: 32px 24px; }
204#home-center p { color: var(--sub); margin: 16px 0 24px; font-size: .95rem; line-height: 1.5; }
205
206/* ── Liste ── */
207#list-header {
208  padding: 10px 16px; font-size: .85rem; color: var(--sub);
209  background: var(--bg); border-bottom: 1px solid var(--border);
210  display: flex; justify-content: space-between; align-items: center;
211  flex-shrink: 0;
212}
213#list-header strong { color: var(--text); }
214#btn-list-print { flex-shrink: 0; background: var(--hover, #f0f4f8); border: 1px solid var(--border);
215  border-radius: 8px; width: 28px; height: 28px; font-size: 14px; line-height: 1; cursor: pointer;
216  display: none; align-items: center; justify-content: center; padding: 0; }
217#btn-list-print.show { display: flex; }
218
219.station-card {
220  background: var(--card); border-bottom: 1px solid var(--border);
221  padding: 14px 16px; display: flex; align-items: flex-start; gap: 12px;
222  cursor: pointer; transition: background .1s;
223  -webkit-tap-highlight-color: transparent;
224}
225.station-card:active { background: #f0f4ff; }
226.station-card-bab { background: #fff7e6; border-left: 4px solid #f59e0b; }
227.bab-badge { display:inline-block; background:#f59e0b; color:#fff; font-size:.68rem; font-weight:700; border-radius:4px; padding:1px 6px; margin-top:2px; }
228.bab-ausfahrt { font-size:.78rem; color:#555; margin-top:2px; }
229
230.station-icon {
231  width: 42px; height: 42px; border-radius: 10px;
232  display: flex; align-items: center; justify-content: center;
233  font-size: 1.3rem; flex-shrink: 0;
234}
235
236.station-body { flex: 1; min-width: 0; }
237.station-name { font-weight: 700; font-size: .95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
238.station-addr { color: var(--sub); font-size: .8rem; margin-top: 2px; }
239.station-prices { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
240.price-tag {
241  font-size: .8rem; font-weight: 700; padding: 2px 8px;
242  border-radius: 20px;
243}
244.price-lpg { background: #dbeafe; color: #1e40af; }
245.price-cng { background: #d1fae5; color: #065f46; }
246.price-none { background: #f3f4f6; color: var(--sub); }
247.trend-up   { color: #dc2626; font-size: .75em; }
248.trend-down { color: #16a34a; font-size: .75em; }
249
250.station-meta { text-align: right; flex-shrink: 0; }
251.dist-badge { font-size: .85rem; font-weight: 700; color: var(--blue); }
252.dist-unit  { font-size: .7rem; color: var(--sub); }
253.h24-badge { display:inline-block; margin-top:4px; background:#16a34a; color:#fff; font-size:.65rem; font-weight:700; border-radius:4px; padding:1px 5px; }
254.age-badge  { font-size: .7rem; color: var(--sub); margin-top: 2px; }
255
256/* 18.09.2026 (User: "der LKW ist bei #26543 verschwunden" - Icon-only-Zeile im 2-spaltigen
257   #detail-infos-Grid war zu klein/unauffaellig, praktisch unsichtbar; User-Wunsch: "reine Badges
258   für EC/KK und LKW die wenig Platz brauchen... brauchen wir ja dann auch für die Liste") -
259   kompakte Pill-Badges analog zu .h24-badge, wiederverwendet in Detail- UND Listenansicht. */
260.feat-badges { display: flex; flex-wrap: wrap; gap: 4px; }
261.feat-badge {
262  display: inline-flex; align-items: center; gap: 3px;
263  font-size: .68rem; font-weight: 700; border-radius: 4px; padding: 2px 6px; line-height: 1.4;
264}
265.feat-badge--lkw  { background: #e5e7eb; color: #374151; }
266.feat-badge--giro { background: #dbeafe; color: #1d4ca3; }
267.feat-badge--kk   { background: #fde2e2; color: #cc0000; }
268
269/* ── Detail ── */
270#view-detail { display: none; }
271#detail-map  { width: 100%; height: 220px; flex-shrink: 0; }
272#detail-body { padding: 16px; }
273#detail-name { font-size: 1.2rem; font-weight: 800; }
274#detail-addr { color: var(--sub); margin-top: 4px; font-size: .9rem; }
275
276.detail-prices {
277  display: flex; gap: 10px; margin: 14px 0;
278}
279.detail-price-box {
280  flex: 1; border-radius: 12px; padding: 12px 14px; text-align: center;
281}
282.detail-price-box.lpg { background: #dbeafe; }
283.detail-price-box.cng { background: #d1fae5; }
284.detail-price-box .gas-label { font-size: .75rem; font-weight: 700; color: var(--sub); text-transform: uppercase; }
285.detail-price-box .gas-price { font-size: 1.6rem; font-weight: 800; margin: 4px 0; }
286.detail-price-box.lpg .gas-price { color: #1e40af; }
287.detail-price-box.cng .gas-price { color: #065f46; }
288.detail-price-box .gas-age   { font-size: .72rem; color: var(--sub); }
289/* 04.09.2026 (User-Idee: Quadrat gleicher Höhe neben der Preis-Box, Check-Haken zur schnellen
290   Preis-Bestätigung ohne das Preismeldungs-Sheet zu öffnen) - aspect-ratio:1 statt fester Breite,
291   damit die Breite automatisch der (variablen) Höhe der Preis-Box in derselben Flex-Zeile folgt. */
292.detail-price-confirm-btn {
293  /* 04.09.2026 Nachbesserung (User: "ist zu schmal!") - aspect-ratio allein liess die Breite auf
294     die (oft geringe) Stretch-Hoehe der Preis-Box in dieser Flex-Zeile schrumpfen; min-width
295     erzwingt jetzt ein brauchbares Tap-Ziel unabhaengig davon. */
296  aspect-ratio: 1 / 1; min-width: 72px; flex: 0 0 auto; border: none; border-radius: 12px;
297  background: #16a34a; display: flex; align-items: center; justify-content: center;
298  cursor: pointer; touch-action: manipulation;
299}
300.detail-price-confirm-btn:active { filter: brightness(.9); }
301
302/* 05.09.2026 (User: "kannst du den Bereich 2-spaltig machen? 7tage & Öffnungszeiten / Telefon &
303   Entfernung / links & rechts-bündig" - "damit sparen wir zwei Zeilen und es sieht kompakter
304   aus"): alle Fakten (24h/7-Tage/LKW/Öffnungszeiten/Telefon/Distanz/Autobahn-Info) liegen als
305   gleichartige row()-Elemente vor und werden vom Grid automatisch in Erzeugungsreihenfolge
306   gepaart - kein separates Chip-/Badge-Konzept mehr noetig. Ungerade Gesamtzahl: letzte Zeile
307   bleibt allein in Spalte 1. Spalte 1 linksbuendig (Icon vorn), Spalte 2 rechtsbuendig gespiegelt
308   (Icon hinten, ueber :nth-child(2n)+row-reverse) statt beide Spalten gleich auszurichten.
309   Trennlinien entfallen, da sie in 2 Spalten optisch nicht mehr sauber durchlaufen wuerden. */
310#detail-infos { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; }
311.detail-info-row {
312  display: flex; align-items: flex-start; gap: 8px;
313  padding: 7px 0;
314  font-size: .9rem; min-width: 0;
315}
316/* 05.09.2026 zweite Nachbesserung (User: "noch immer ist rechts leerer Raum!") - width:100% +
317   justify-content:flex-end blieb sichtbar wirkungslos. justify-self:end ist die eigentlich
318   dafuer vorgesehene Grid-Eigenschaft: schiebt die Zeile (jetzt wieder shrink-to-fit statt
319   erzwungener voller Spaltenbreite) als Ganzes an den rechten Rand IHRER Grid-Zelle. */
320.detail-info-row:nth-child(2n) { justify-self: end; flex-direction: row-reverse; text-align: right; }
321.detail-info-icon { width: 22px; text-align: center; color: var(--blue); flex-shrink: 0;
321 margin-top: 1px; }
322
323.detail-actions { display: flex; gap: 10px; margin-top: 16px; }
324.action-btn {
325  flex: 1; border: none; border-radius: 12px; padding: 14px;
326  font-size: .9rem; font-weight: 700; cursor: pointer; text-align: center;
327  text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 6px;
328}
329.action-btn.nav  { background: var(--blue); color: #fff; }
330.action-btn.info { background: var(--bg); color: var(--blue); border: 2px solid var(--blue); }
331.action-btn.fav  { background: var(--bg); color: #e11d48; border: 2px solid #e11d48; flex: 0 0 56px; }
332.action-btn.fav.active { background: #e11d48; color: #fff; }
333
334/* ── Favoriten (Home) ── */
335#fav-section { width: 100%; max-width: 420px; margin: 8px auto 0; text-align: left; }
336#fav-section h3 { font-size: .85rem; color: var(--sub); margin: 0 0 6px 4px; text-transform: uppercase; letter-spacing: .03em; }
337.fav-row {
338  display: flex; align-items: center; gap: 10px; background: #fff; border-radius: 10px;
339  padding: 10px 12px; margin-bottom: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.06); cursor: pointer;
340}
341.fav-row-body { flex: 1; min-width: 0; text-align: left; }
342.fav-row-name { font-size: .88rem; font-weight: 700; color: #222; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
343.fav-row-addr { font-size: .76rem; color: var(--sub); }
344.fav-row-prices { margin-top: 4px; display: flex; gap: 6px; flex-wrap: wrap; }
345.fav-row-prices .price-tag { font-size: .68rem; padding: 2px 6px; }
346.fav-row-remove { flex-shrink: 0; background: none; border: none; color: #c0392b; font-size: 1.1rem; cursor: pointer; padding: 4px 6px; }
347.fav-tabs { width: 100%; max-width: 420px; margin: 18px auto 0; text-align: center; }
348.fav-tab { display: inline-flex; align-items: center; justify-content: center;
349  width: 48px; height: 48px; font-size: 1.25rem; background: var(--card);
350  border: 2px solid var(--border); border-radius: 10px; cursor: pointer;
351  font-family: inherit; color: var(--sub); transition: all .15s; }
352.fav-tab + .fav-tab { margin-left: 8px; }
353.fav-tab[data-tab="local"].active  { border-color: #e11d48; color: #e11d48; background: #fff1f2; }
354.fav-tab[data-tab="profile"].active { border-color: #f59e0b; color: #f59e0b; background: #fffbeb; }
355.action-btn.fav-profile { background: var(--bg); color: #f59e0b; border: 2px solid #f59e0b; flex: 0 0 56px; }
356.action-btn.fav-profile.active { background: #f59e0b; color: #fff; }
357
358/* ── Preismeldungs-Sheet ── */
359#price-sheet-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1000; }
360#price-sheet-overlay.open { display: block; }
361
362/* 05.09.2026 (User-Idee: nach erfolgreichem Quick-Jump-Update statt Fenster-Schliessen (auf iOS
363   technisch unmoeglich) wieder den gruenen q.php-Button zeigen, damit direkt die naechste
364   Tankstelle drankommt) - optisch identisch zu mobile/q.php's .go-Button, aber ohne Anleitung. */
365/* :not([hidden]) statt display auf dem ID-Selektor selbst - eine ID hat hoehere Spezifitaet als
366   die UA-Regel "[hidden]{display:none}" und wuerde sie sonst ausser Kraft setzen, das Overlay
367   waere trotz hidden-Attribut permanent sichtbar (per Browser-Test am 05.09.2026 gefunden). */
368#quickjump-again-overlay { position: fixed; inset: 0; z-index: 2000; background: #15803d; }
369#quickjump-again-overlay:not([hidden]) { display: flex; align-items: center; justify-content: center; }
370#qj-again-btn { display: flex; flex-direction: column; align-items: center; gap: 14px;
371  color: #fff; background: rgba(255,255,255,.08); border: none; border-radius: 20px;
372  padding: 32px 40px; font-family: inherit; }
373#qj-again-btn svg { width: 56px; height: 56px; }
374#qj-again-btn span { font-size: 1.15rem; font-weight: 700; }
375#price-sheet { position: absolute; left: 0; right: 0; bottom: 0;
376  background: var(--card); border-radius: 18px 18px 0 0; overflow: hidden; }
377.price-sheet-btn { display: block; width: 100%; padding: 16px 18px;
378  background: none; border: none; border-bottom: 1px solid var(--border);
379  font-size: 1rem; font-family: inherit; color: var(--blue); cursor: pointer; text-align: center; }
380.price-sheet-btn.danger { color: #dc2626; }
381.price-sheet-btn.cancel { color: var(--text); font-weight: 600; }
382.price-sheet-btn:disabled { opacity: .45; cursor: default; }
383/* 04.09.2026 (User-Wunsch: "die Preiseingabe per Eingabe-Feld ist so wie es ist unterwegs
384   nicht gut gemacht" - reines Zahlenfeld+Tastatur ist mit einer Hand/Handschuhen am Automaten
385   unhandlich): Stepper mit grossen Tap-Zielen um das Preisfeld herum, Direkteingabe per Tippen
386   ins Feld bleibt als Fallback erhalten (z.B. fuer groessere Preisspruenge). */
387#ps-price-input-row { display: none; padding: 12px 18px; border-bottom: 1px solid var(--border);
388  flex-direction: column; gap: 10px; }
389#ps-price-input-row.open { display: flex; }
390.ps-stepper { display: flex; align-items: center; gap: 10px; }
391.ps-step-btn { flex: 0 0 52px; height: 52px; border-radius: 12px; border: 2px solid var(--border);
392  background: var(--bg); color: var(--blue); font-size: 1.6rem; font-weight: 700; line-height: 1;
393  font-family: inherit; cursor: pointer; user-select: none; touch-action: manipulation; }
394.ps-step-btn:active { background: var(--blue); color: #fff; border-color: var(--blue); }
395#ps-price-input { flex: 1; min-width: 0; padding: 10px 12px; border: 2px solid var(--border);
396  border-radius: 10px; font-size: 1.4rem; font-family: inherit; text-align: center;
397  background: var(--bg); color: var(--text); }
398#ps-price-input:focus { outline: none; border-color: var(--blue); }
399#ps-price-submit { padding: 12px 16px; background: var(--blue); color: #fff; width: 100%;
400  border: none; border-radius: 10px; font-size: 1rem; font-family: inherit; cursor: pointer; }
401#ps-msg-row { display: none; padding: 12px 18px; border-bottom: 1px solid var(--border);
402  flex-direction: column; gap: 8px; }
403#ps-msg-row.open { display: flex; }
404#ps-msg-input { padding: 10px 12px; border: 2px solid var(--border); border-radius: 10px;
405  font-size: .95rem; font-family: inherit; background: var(--bg); color: var(--text);
406  resize: none; rows: 3; }
407#ps-msg-input:focus { outline: none; border-color: var(--blue); }
408#ps-msg-submit { padding: 10px 16px; background: var(--blue); color: #fff;
409  border: none; border-radius: 10px; font-size: 1rem; font-family: inherit; cursor: pointer; align-self: flex-end; }
410
411/* ── Karten-View ── */
412#view-map { position: relative; }
413#full-map { width: 100%; flex: 1; }
414/* 18.07.26: Radius-Stufen direkt auf der Karte umschalten (User-Wunsch) */
415#map-radius-ctrl {
416  /* 11.08.2026 User-Wunsch: etwas tiefer (naeher am unteren Rand), zweite Nachbesserung -15px */
417  position: absolute; bottom: calc(-5px + var(--safe-b)); left: 50%; transform: translateX(-50%);
418  display: flex; align-items: center; gap: 2px;
419  background: var(--blue); color: #fff; border-radius: 50px;
420  box-shadow: 0 4px 12px rgba(0,0,0,.3);
421  z-index: 500;
422}
423#map-radius-ctrl button {
424  background: none; border: none; color: #fff; font-size: 1.3rem; font-weight: 700;
425  width: 40px; height: 40px; cursor: pointer; line-height: 1;
426}
427#map-radius-ctrl button:disabled { opacity: .35; cursor: default; }
428#map-radius-label { font-size: .9rem; font-weight: 700; padding: 0 4px; min-width: 52px; text-align: center; }
429/* 11.08.2026 User-Idee: optionaler "Groß"-Schalter fuer die Karte (bessere Lesbarkeit bei
430   schlechten Sichtverhaeltnissen, dafuer weniger Tankstellen gleichzeitig im Bild) - siehe
431   toggleMapLargeMode() unten. */
432#map-large-toggle {
433  position: absolute; top: calc(10px + var(--safe-t)); right: 10px; z-index: 500;
434  background: var(--blue); color: #fff; border: none; border-radius: 50%;
435  width: 40px; height: 40px; font-size: 1.15rem; cursor: pointer;
436  box-shadow: 0 4px 12px rgba(0,0,0,.3);
437}
438#map-large-toggle.active { background: #e67e22; }
439/* 11.08.2026 User-Idee: optionaler "Auto"-Modus fuer die Karte - aktualisiert Standort+Karte
440   automatisch alle 5 Minuten (fuers Fahren, kein manuelles GPS-Antippen noetig). Eigener
441   Schalter statt drittem Zustand des Groß-Schalters (User-Wunsch: "als eigener Schalter!"),
442   direkt darunter positioniert. Siehe mapAutoRefresh()/startMapAutoTracking() im Script. */
443#map-auto-toggle {
444  position: absolute; top: calc(58px + var(--safe-t)); right: 10px; z-index: 500;
445  background: var(--blue); color: #fff; border: none; border-radius: 50%;
446  width: 40px; height: 40px; font-size: 1.15rem; cursor: pointer;
447  box-shadow: 0 4px 12px rgba(0,0,0,.3);
448}
449#map-auto-toggle.active { background: #27ae60; }
450
451/* ── Bottom Nav ── */
452#bottom-nav {
453  display: flex; background: #fff; border-top: 1px solid var(--border);
454  padding-bottom: var(--safe-b);
455  flex-shrink: 0;
456}
457.nav-btn {
458  flex: 1; min-width: 0; border: none; background: none; padding: 10px 2px 8px;
459  display: flex; flex-direction: column; align-items: center; gap: 3px;
460  color: var(--sub); font-size: clamp(.56rem, 2.5vw, .68rem); font-weight: 600; cursor: pointer;
461  transition: color .15s;
462  position: relative;
463  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
464  /* 11.08.2026 User-Wunsch: verhindert, dass ein langer Fingerdruck auf einen Nav-Button
465     (auch auf denen OHNE eigene Long-Press-Funktion) die native Text-Markierung/das
466     Callout-Menü des Browsers ausloest - unabhaengig vom JS-Touch-Timer, betrifft alle Buttons. */
467  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
468}
469/* 28.08.2026 (User-Fund: "PWA-Interface minimal zu breit, scrollt horizontal" - seit dem
470   Tankbuch-Button sind es 7 Buttons in der Fussleiste. Flex-Kinder schrumpfen per Default
471   NICHT unter ihre Inhaltsbreite (min-width:auto), ein einzelnes langes Wort wie "Tankbuch"
472   erzwingt dadurch eine Mindestbreite, die auf schmalen Screens die gesamte Seite in die Breite
473   reisst. Fix: min-width:0 oben erlaubt das Schrumpfen ueberhaupt erst, clamp() unten skaliert
474   Icon+Schrift fliessend mit der Viewport-Breite ("passend zur maximalen Display-Breite
475   gezoomt") statt hart abzuschneiden. */
476.nav-btn svg { width: clamp(18px, 5.5vw, 22px); height: clamp(18px, 5.5vw, 22px); flex-shrink: 0; }
477.nav-btn.active { color: var(--blue); }
478/* 28.08.2026 (User-Wunsch: "Tankbuch ausgrauen, wenn nicht angemeldet") - rein visueller Hinweis,
479   der eigentliche Zugriffsschutz laeuft weiterhin serverseitig ueber mobile/api/tankbuch.php. */
480.nav-btn.nav-btn-locked { opacity: .4; }
481/* 11.08.2026 User-Idee: kleiner Punkt markiert Buttons mit Long-Press-Funktion (Umkreissuche
482   am GPS-Standort) - bewusst NICHT var(--blue) (schon fuer den aktiven Tab reserviert, sonst
483   verwechselbar) und ein Punkt statt eines vollen Balkens (dezent, kein Notification-Look). */
484.nav-btn.has-longpress::after {
485  content: '';
486  position: absolute;
487  top: 6px;
488  left: calc(50% + 9px);
489  width: 5px; height: 5px;
490  border-radius: 50%;
491  background: #e67e22;
492}
493
494/* ── Loader ── */
495#loader {
496  display: none; position: fixed; inset: 0; background: rgba(255,255,255,.7);
497  justify-content: center; align-items: center; z-index: 100;
498}
499#loader.show { display: flex; }
500.spinner {
501  width: 40px; height: 40px; border: 4px solid var(--border);
502  border-top-color: var(--blue); border-radius: 50%; animation: spin .8s linear infinite;
503}
504@keyframes spin { to { transform: rotate(360deg); } }
505
506/* ── Toast ── */
507/* 10.07.26: von unten (bottom) nach oben (top) verlegt - unten wurde die Meldung bei
508   geoeffneter virtueller Tastatur (z.B. waehrend der Eingabe im Suchfeld) verdeckt. */
509/* 26.09.2026 (User-Fund: "das Feedback für erfolgreichen Preis bestätigen ist schlecht zu sehen!
510   Den Text viel größer und mittig", "doch den Toast global vergrößern!", dann nach dem ersten
511   Versuch: "viel zu klein und nicht in der Mitte!") - jetzt wirklich BILDSCHIRMMITTE
512   (top:50%/left:50%, beide Achsen zentriert) statt nur oben horizontal zentriert, deutlich
513   groessere Schrift. Bleibt trotzdem unproblematisch mit der Tastatur (10.07.26-Grund oben):
514   die Bildschirmmitte liegt bei geoeffneter virtueller Tastatur normalerweise noch darueber. */
515#toast {
516  display: none; position: fixed; top: 50%; left: 50%;
517  transform: translate(-50%, -50%); background: #333; color: #fff;
518  padding: 26px 34px; border-radius: 20px; font-size: 1.6rem; font-weight: 800; z-index: 500;
519  white-space: normal; max-width: 85vw; text-align: center; line-height: 1.3;
520  box-shadow: 0 8px 30px rgba(0,0,0,.35);
521}
522/* 26.09.2026 (User-Wunsch: "grüner Hintergrund (da erfolgreich)" / "roter Hintergrund, wenn nicht
523   erfolgreich") - optionale Erfolg/Fehler-Faerbung ueber showToast()s dritten Parameter. Bewusst
524   NICHT der Default (bliebe sonst neutral dunkel wie bisher) - nur an der Preis-Rueckmeldung
525   (priceReport()) angebunden, dem konkret gemeldeten Fall. Andere Toasts (Favoriten, Login, GPS
526   etc.) bleiben unveraendert neutral, koennten bei Bedarf spaeter genauso eingefaerbt werden. */
527#toast.toast--success { background: var(--green); }
528#toast.toast--error   { background: #c0392b; }
529/* 11.08.2026 User-Wunsch: eigener Toast am oberen Kartenrand statt des globalen #toast (der
530   liegt ueber dem Header, nicht ueber der Karte) - nur fuer Karten-bezogene Hinweise wie
531   "Auto-Folgen an/aus", siehe showMapToast(). */
532#map-toast {
533  display: none; position: absolute; top: calc(10px + var(--safe-t)); left: 50%;
534  transform: translateX(-50%); background: #333; color: #fff;
535  padding: 10px 20px; border-radius: 20px; font-size: .85rem; z-index: 600;
536  white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
537}
538
539/* ── Route-View ── */
540#view-route { flex-direction: column; overflow: hidden; flex: 1; min-height: 0; }
541#route-form-panel { overflow-y: auto; flex: 1; min-height: 0; padding: 16px 16px 24px; }
542#route-result-panel { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }
543#route-map-container { flex: 0 0 52%; min-height: 0; }
544#route-result-list { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px 16px; }
545
546.route-field { margin-bottom: 14px; }
547.route-field label { display: block; font-size: .78rem; color: var(--sub); font-weight: 700;
548  margin-bottom: 5px; text-transform: uppercase; letter-spacing: .03em; }
549.route-field input[type=text], .route-field input[type=number] {
550  width: 100%; box-sizing: border-box; border: 1.5px solid var(--border);
551  border-radius: 10px; padding: 11px 12px; font-size: .95rem; outline: none;
552  font-family: inherit; background: #fff; }
553.route-field input:focus { border-color: var(--blue); }
554.route-input-wrap { position: relative; }
555.route-input-wrap input { padding-right: 72px; }
556/* 18.07.26: "GPS"-Text statt reinem 📍-Icon - auf Touchgeraeten gibt es keinen
557   Hover fuer den bisherigen title="Meinen Standort als Start"-Tooltip, das
558   Icon allein wurde nicht als "GPS-Standort nutzen" erkannt (User
558-Feedback). */
559.btn-gps-inline { position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
560  background: none; border: 1.5px solid var(--blue); border-radius: 8px; cursor: pointer;
561  padding: 5px 8px; color: var(--blue); line-height: 1; font-size: .78rem; font-weight: 700;
562  white-space: nowrap; }
563
564.route-suggestions { background: #fff; border: 1px solid var(--border); border-radius: 10px;
565  margin-top: 3px; box-shadow: 0 6px 16px rgba(0,0,0,.12); display: none;
566  max-height: 170px; overflow-y: auto; z-index: 50; position: relative; }
567.route-suggestions.show { display: block; }
568.route-sugg-item { padding: 10px 12px; font-size: .88rem; cursor: pointer;
569  border-bottom: 1px solid var(--border); }
570.route-sugg-item:last-child { border-bottom: none; }
571.route-sugg-item:active { background: #f0f4f8; }
572.route-sugg-main { font-weight: 600; color: #222; }
573.route-sugg-sub  { font-size: .75rem; color: var(--sub); margin-top: 1px; }
574
575.route-filter-toggle { background: none; border: 1px solid var(--border, #d1d5db); color: inherit; border-radius: 8px;
576  padding: 6px 12px; font-size: .85rem; cursor: pointer; margin-bottom: 10px; }
577#route-filter-box { margin-bottom: 10px; }
578.route-filter-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: .9rem; }
579.route-filter-row input { width: 18px; height: 18px; }
580.route-mode-row { display: flex; gap: 8px; margin-bottom: 14px; }
581.route-mode-btn { flex: 1; border: 1.5px solid var(--border); border-radius: 9px;
582  background: #fff; padding: 9px 4px; font-size: .82rem; font-weight: 700;
583  cursor: pointer; color: var(--sub); transition: all .15s; font-family: inherit; }
584.route-mode-btn.active { background: var(--blue); color: #fff; border-color: var(--blue); }
585
586.route-two-col { display: flex; gap: 10px; margin-bottom: 6px; }
587.route-two-col .route-field { flex: 1; margin-bottom: 0; }
588.route-two-col .route-field input { width: 100%; box-sizing: border-box; }
589input[type=number]::-webkit-inner-spin-button,
590input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; }
591input[type=number] { -moz-appearance: textfield; }
592
593.route-reserve-hint { font-size: .74rem; color: var(--sub); margin: -2px 0 14px; }
594.route-avoid-row { display:flex; align-items:center; justify-content:space-between;
595  font-size:.88rem; color:var(--sub); margin-bottom:14px; }
596.route-toggle { position:relative; display:inline-block; width:42px; height:24px; flex-shrink:0; }
597.route-toggle input { opacity:0; width:0; height:0; }
598.route-toggle-slider { position:absolute; cursor:pointer; inset:0; background:#d1d5db;
599  border-radius:24px; transition:.3s; }
600.route-toggle-slider:before { position:absolute; content:''; height:18px; width:18px;
601  left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.3s; }
602input:checked + .route-toggle-slider { background:var(--blue); }
603input:checked + .route-toggle-slider:before { transform:translateX(18px); }
604.wp-item { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #f3f4f6; }
605.wp-num { width:22px; height:22px; border-radius:50%; background:var(--blue); color:#fff;
606  font-size:.72rem; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
607.wp-label { flex:1; font-size:.87rem; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
608.wp-del { background:none; border:none; color:#bbb; font-size:1rem; cursor:pointer; padding:4px; flex-shrink:0; }
609.wp-del:hover { color:#e33; }
610#btn-add-wp { display:block; background:none; border:none; color:var(--blue);
611  font-size:.87rem; cursor:pointer; padding:6px 0 10px; font-weight:600; width:100%; text-align:left; }
612.wp-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:9998; }
613.wp-sheet { position:fixed; bottom:0; left:0; right:0; background:#fff; border-radius:18px 18px 0 0;
614  padding:20px 16px calc(20px + env(safe-area-inset-bottom)); z-index:9999;
615  transform:translateY(100%); transition:transform .28s cubic-bezier(.4,0,.2,1); max-height:70vh; overflow-y:auto; }
616.wp-sheet.wp-open { transform:translateY(0); }
617.wp-sheet-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
618.wp-sheet-title span { font-weight:700; font-size:1rem; }
619.wp-sheet-title button { background:none; border:none; font-size:1.3rem; color:#666; cursor:pointer; padding:4px 6px; }
620#wp-add-btn { margin-top:14px; width:100%; background:var(--blue); color:#fff; border:none;
621  border-radius:10px; padding:12px; font-size:.95rem; font-weight:600; cursor:pointer; }
622#wp-add-btn:disabled { opacity:.4; cursor:default; }
623#wp-input { width:100%; box-sizing:border-box; border:1.5px solid var(--border);
624  border-radius:10px; padding:13px 14px; font-size:1rem; outline:none;
625  font-family:inherit; background:#fff; }
626#wp-input:focus { border-color:var(--blue); }
627#route-history-row { display:none; margin-bottom:12px; }
628#route-history-row.has-items { display:block; }
629.rh-label { font-size:.72rem; color:var(--sub); font-weight:700; text-transform:uppercase;
630  letter-spacing:.04em; margin-bottom:6px; }
631.rh-chips { display:flex; gap:8px; overflow-x:auto; padding-bottom:2px; -webkit-overflow-scrolling:touch; }
632.rh-chip { flex-shrink:0; display:flex; align-items:center; gap:6px; background:var(--card);
633  border:1.5px solid var(--border); border-radius:20px; padding:6px 10px 6px 12px;
634  font-size:.82rem; cursor:pointer; max-width:220px; white-space:nowrap; }
635.rh-chip:hover { border-color:var(--blue); }
636.rh-chip-text { overflow:hidden; text-overflow:ellipsis; flex:1; min-width:0; }
637.rh-chip-del { flex-shrink:0; background:none; border:none;
637 color:#bbb; font-size:.85rem;
638  cursor:pointer; padding:0 2px; line-height:1; }
639.rh-chip-del:hover { color:#e33; }
640#btn-route-calc { width: 100%; background: var(--blue); color: #fff; border: none;
641  border-radius: 12px; padding: 14px; font-size: 1rem; font-weight: 700;
642  cursor: pointer; font-family: inherit; }
643#btn-route-calc:disabled { opacity: .5; cursor: default; }
644@keyframes route-spin { to { transform: rotate(360deg); } }
645
646#route-error-msg { background: #fee2e2; border-radius: 10px; padding: 12px 14px;
647  margin-bottom: 10px; color: #991b1b; font-size: .85rem; }
648#route-info-bar { font-size: .78rem; color: var(--sub); margin-bottom: 12px;
649  display: flex; align-items: center; justify-content: space-between; gap: 8px; }
650#btn-route-print { flex-shrink: 0; background: var(--hover, #f0f4f8); border: 1px solid var(--border);
651  border-radius: 8px; width: 30px; height: 30px; font-size: 15px; line-height: 1; cursor: pointer;
652  display: flex; align-items: center; justify-content: center; padding: 0; }
653.route-stop-card { background: #fff; border-radius: 12px; padding: 12px 14px;
654  margin-bottom: 9px; box-shadow: 0 1px 6px rgba(0,0,0,.08);
655  display: flex; gap: 12px; align-items: flex-start; cursor: pointer; }
656.route-stop-num { flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%;
657  background: #e87800; color: #fff; font-weight: 800; font-size: .85rem;
658  display: flex; align-items: center; justify-content: center; }
659.route-stop-body { flex: 1; min-width: 0; }
660.route-stop-name { font-weight: 700; font-size: .9rem; white-space: nowrap;
661  overflow: hidden; text-overflow: ellipsis; }
662.route-stop-addr { font-size: .76rem; color: var(--sub);
662 margin-top: 1px; }
663.route-stop-meta { display: flex; gap: 7px; margin-top: 5px; flex-wrap: wrap; align-items: center; }
664.route-no-stop { font-size: .9rem; color: #16a34a; font-weight: 700;
665  padding: 12px 0 4px; text-align: center; }
666.route-all-toggle { font-size: .82rem; color: var(--blue); cursor: pointer;
667  margin: 10px 0 4px; font-weight: 700; padding: 4px 0; }
668#route-all-list { display: none; }
669#route-all-list.show { display: block; }
670.route-station-row { display: flex; align-items: center; gap: 10px; padding: 8px 0;
671  border-bottom: 1px solid var(--border); cursor: pointer; }
672.route-station-row:last-child { border-bottom: none; }
673.route-station-dot { flex-shrink: 0; width: 11px; height: 11px; border-radius: 50%; }
674.route-station-info { flex: 1; min-width: 0; }
675.route-station-name { font-size: .85rem; font-weight: 600; white-space: nowrap;
676  overflow: hidden; text-overflow: ellipsis; }
677.route-station-addr { font-size: .73rem; color: var(--sub); }
678#btn-route-reset { width: 100%; background: #f0f4f8; color: #555; border: none;
679  border-radius: 10px; padding: 12px; font-size: .88rem; font-weight: 600;
680  cursor: pointer; margin-top: 10px; font-family: inherit; }
681
682/* 10.08.26: Druckansicht auch in der PWA (User-Wunsch: "Auch dort (es gibt ja auch Tablets) eine
683   druckbare Liste anbieten", spaeter erweitert auf die Umkreissuche-Liste: "fuer die Liste der
684   Umkreissuche waere ein Druck auch sinnvoll"), analog zu routeplaner.php (Desktop). Beide Sheets
685   (.rpp-sheet) sind am Bildschirm immer unsichtbar; @media print blendet die gesamte App-Shell
686   (#app) aus und zeigt nur das jeweils gefuellte Sheet in festen hellen Farben (Dark-Mode soll
687   nicht mitgedruckt werden - Tinte/Lesbarkeit). */
688.rpp-sheet { display: none; }
689@media print {
690  #app { display: none !important; }
691  .rpp-sheet { display: block; position: static; width: 100%;
692    color: #111; background: #fff; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
693  .rpp-sheet h1 { font-size: 18px; margin: 0 0 4px; }
694  .rpp-sheet .rpp-meta { font-size: 12px; color: #555; margin-bottom: 14px; }
695  .rpp-sheet table { width: 100%; border-collapse: collapse; font-size: 12px; }
696  .rpp-sheet th, .rpp-sheet td { text-align: left; padding: 5px 8px;
697    border-bottom: 1px solid #ccc; }
698  .rpp-sheet th { color: #555; font-weight: 600; }
699  .rpp-sheet .rpp-num { width: 26px; height: 26px; border-radius: 50%; background: #e87800;
700    color: #fff; font-weight: 700; font-size: 12px; text-align: center; line-height: 26px;
701    display: inline-block; }
702}
703
704/* ── Profil / Login ── */
705#view-profile { flex: 1; min-height: 0; padding: 20px 16px; overflow-y: auto; display: flex; flex-direction: column; align-items: center; }
706#profile-login-form, #profile-loggedin { width: 100%; max-width: 420px; }
707
708/* ── Hilfe ── */
709#view-help { flex: 1; min-height: 0; padding: 20px 16px; overflow-y: auto; display: flex; flex-direction: column; align-items: center; }
710#view-help code { background: var(--bg); border-radius: 4px; padding: 1px 6px; font-size: .82em; }
711.p-card { background: var(--card); border-radius: 14px; padding: 20px; margin-bottom: 14px;
712  box-shadow: 0 2px 8px rgba(0,0,0,.06); }
713/* 28.08.2026 (PLAN Tankbuch, User-Fund am echten Geraet: Tankbuch-Liste war hier komplett
714   ungestylt - .tb-*-Klassen waren bisher nur im <style> von tankbuch.php definiert, die PWA
715   bekam sie nie zu Gesicht. Kompaktes Karten-Layout statt Tabelle, siehe renderList() in
716   js/tankbuch.js, identisch zu tankbuch.php gehalten. */
717.tb-entry { border-bottom: 1px solid var(--border); padding: 10px 0; }
718.tb-entry:last-child { border-bottom: none; }
719.tb-entry-row { display: flex; justify-content: space-between; gap: 10px; font-weight: 600; font-size: .88rem; }
720.tb-entry-detail { color: var(--sub); font-size: .78rem; margin-top: 2px; }
721.tb-entry-actions { display: flex; gap: 8px; margin-top: 6px; }
722.tb-entry-actions button { font-size: .74rem; padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border); background: var(--card); color: var(--text); cursor: pointer; }
723.tb-empty { color: var(--sub); font-style: italic; }
724/* 28.08.2026 (User-Wunsch: "die PWA nur zur Eingabe/Kontrolle der letzten 3 Tankvorgaenge") -
725   Hinweis auf die volle Desktop-Verwaltung, erscheint nur in der PWA (moreLink-Option). Bewusst
726   kein Link (User-Wunsch: "nicht als Link, sondern nur als Hinweis!") - reiner Text. */
727.tb-more-link { text-align: center; padding-top: 10px; font-size: .8rem; color: var(--sub); }
728/* 28.08.2026 (User-Wunsch: "wir brauchen dann noch einen design-loesung, wenn viele
729   Tankvorgaenge vorliegen") - feste Maximalhoehe mit eigenem Scrollbereich, damit die Liste bei
730   Monaten/Jahren an Eintraegen nicht die ganze View endlos in die Laenge zieht. Kleinerer Wert
731   als auf der Desktop-Seite, da in der PWA ohnehin schon Formular+Chart+Liste untereinander
732   in einer scrollbaren View stehen. */
733#tb-list { max-height: 45vh; overflow-y: auto; }
734.p-card h2 { font-size: 1rem; font-weight: 700; margin-bottom: 14px; }
735.p-field { margin-bottom: 12px; }
736.p-field label { display: block; font-size: .72rem; color: var(--sub); font-weight: 700;
737  margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; }
738.p-field input, .p-field select { width: 100%; border: 1.5px solid var(--border); border-radius: 9px;
739  padding: 11px 13px; font-size: .95rem; line-height: 1.3; font-family: inherit; outline: none; background: var(--card); color: var(--text); }
740.p-field input:focus, .p-field select:focus { border-color: var(--blue); }
741/* 28.08.2026 (User-Fund: Kraftstoff-/Fahrzeug-Auswahl sah auf dem echten Geraet anders aus als
742   die uebrigen Formularfelder - hoehere Box, blaue System-Fokus-Umrandung, eigener Pfeil) -
743   natives Select-Aussehen zuruecksetzen und einen dezenten eigenen Pfeil zeichnen, damit Input
744   UND Select in der PWA identisch aussehen, auf jeder Plattform (iOS/Android/Desktop). */
745.p-field select { -webkit-appearance: none; -moz-appearance: none; appearance: none;
746  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='11' height='7'><path d='M1 1l4.5 4.5L10 1' stroke='%236b7280' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
747  background-repeat: no-repeat; background-position: right 14px center; padding-right: 34px; }
748/* 28.08.2026 (User-Frage: "kann man die Eingabe von Preis eleganter gestalten?") - fest
749   eingeblendetes Waehrungssymbol im Feld statt nur im Label, macht die Einheit beim Tippen
750   sichtbar. */
751.tb-prefix-wrap { position: relative; }
752.tb-prefix { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--sub); font-size: .95rem; pointer-events: none; }
753.tb-prefix-wrap input { padding-left: 28px !important; }
754.btn-login  { width: 100%; background: var(--blue); color: #fff; border: none;
755  border-radius: 10px; padding: 13px; font-size: 1rem; font-weight: 700;
756  cursor: pointer; font-family: inherit; }
757.btn-login:disabled { opacity: .5; }
758/* 16.08.26 User-Wunsch: kleine, runde Icon-Buttons fuer Apple/Google-Login DIREKT NEBEN dem
759   "Anmelden"-Button statt vollbreiter Buttons unter einem "oder"-Trenner (Vorbild: gaengiges
760   Icon-Login-Muster, User hat Screenshot gezeigt). .btn-login bleibt in dieser Zeile flexibel
761   statt 100% breit, IDs/Click-Handler unveraendert. */
762.login-btn-row { display: flex; align-items: center; gap: 10px; }
763.login-btn-row .btn-login { width: auto; flex: 1; }
764.btn-oauth-icon { flex-shrink: 0; width: 46px; height: 46px; border-radius: 50%;
765  display: flex; align-items: center; justify-content: center;
766  cursor: pointer; border: none; padding: 0; }
767.btn-oauth-icon.btn-apple  { background: #000; color: #fff; }
768.btn-oauth-icon.btn-google { background: #fff; color: #3c4043; border: 1.5px solid #dadce0 !important; }
769.btn-logout { width: 100%; background: #fee2e2; color: #dc2626; border: none;
770  border-radius: 9px; padding: 11px; font-size: .9rem; font-weight: 700;
771  cursor: pointer; font-family: inherit; margin-top: 12px; }
772.p-error { color: #dc2626; font-size: .82rem; margin: 6px 0 8px; display: none; }
773.p-avatar { width: 50px; height: 50px; border-radius: 50%; background: var(--blue);
774  color: #fff; display: flex; align-items: center; justify-content: center;
775  font-size: 1.4rem; font-weight: 700; flex-shrink: 0; }
776.p-user-row { display: flex; align-items: center; gap: 14px; }
777.p-hint-row { font-size: .82rem; color: var(--sub); padding: 5px 0; }
778.gas-pick-row { display: flex; gap: 8px; margin-top: 8px; }
779.gas-pick-btn { flex: 1; padding: 9px 6px; border-radius: 9px; border: 2px solid var(--border);
780  background: var(--card); color: var(--text); font-size: .9rem; font-weight: 600;
781  cursor: pointer; font-family: inherit; transition: all .15s; }
782.gas-pick-btn.active { border-color: var(--blue); background: var(--blue); color: #fff; }
783.p-hint-row + .p-hint-row { margin-top: 4px; }
784.nav-btn.is-logged-in svg { stroke: var(--green); }
785
786/* 27.07.26 Dark-Mode Fortsetzung: einzelne Komponenten setzen Farben hartcodiert statt ueber
787   die Tokens (#fff-Flaechen, dunkler Text auf hellem Grund) - die faerben sich durch die
788   Token-Umdefinition oben NICHT automatisch um und muessten sonst als heller Kasten auf
789   dunklem Grund haengen bleiben. Gezielte Overrides fuer genau diese Faelle. */
790:root[data-theme="dark"] #gps-radius, :root[data-theme="dark"] .fav-row,
791:root[data-theme="dark"] .route-suggestions, :root[data-theme="dark"] .route-mode-btn,
792:root[data-theme="dark"] .route-field input[type=text], :root[data-theme="dark"] .route-field input[type=number],
793:root[data-theme="dark"] .wp-sheet, :root[data-theme="dark"] #wp-input, :root[data-theme="dark"] .route-stop-card { background: var(--card); }
794:root[data-theme="dark"] #bottom-nav { background: var(--card); }
795:root[data-theme="dark"] #loader { background: rgba(0,0,0,.7); }
796:root[data-theme="dark"] .station-card:active { background: rgba(77,127,214,.15); }
797:root[data-theme="dark"] .fav-row-name, :root[data-theme="dark"] .route-sugg-main, :root[data-theme="dark"] .wp-label { color: var(--text); }
798:root[data-theme="dark"] .fav-tab[data-tab="local"].active   { background: rgba(225,29,72,.15); }
799:root[data-theme="dark"] .fav-tab[data-tab="profile"].active { background: rgba(245,158,11,.15); }
800:root[data-theme="dark"] #route-error-msg { background: rgba(220,38,38,.18); }
801:root[data-theme="dark"] .station-card-bab { background: rgba(245,158,11,.12); }
802
803/* ── Scanner-View (27.07.26, Integration des bisher isolierten mobile/scanner.php-Prototyps,
804   siehe Memory project_pwa_scanner) - eigene, bewusst IMMER dunkle Sonar-Optik, unabhaengig vom
805   Light/Dark-Mode der restlichen App (wie ein Kamera-/Radar-Bildschirm, der auch in hellen Apps
806   dunkel bleibt) - daher eigene, prefixte CSS-Variablen statt der globalen --bg/--card/--text. */
807#view-scanner {
808  flex: 1; min-height: 0; display: none; flex-direction: column;
809  background: var(--sc-bg); color: var(--sc-text);
810  --sc-bg: #05070a; --sc-panel: #0d1520; --sc-line: #123322;
811  --sc-scan: #22e07a; --sc-scan2: #0fae5c; --sc-text: #d7fbe8; --sc-sub: #6f9484; --sc-amber: #ffb020;
812}
813/* 28.07.26 User-Wunsch: Querformat im Scanner verhindern - das Radar-Layout ist fest auf
814   Hochkant ausgelegt (Handy typischerweise im Auto-Halter). Echtes screen.orientation.lock()
815   funktioniert nur in installierten Android-PWAs, NICHT auf iOS/Safari (Apple unterstuetzt die
816   Screen-Orientation-Lock-API grundsaetzlich nicht) - der Lock-Versuch dafuer steht im Script
817   weiter unten (startScannerTracking()), rein als Bonus fuer Android. Zuverlaessig auf allen
818   Geraeten ist nur diese CSS-Loesung: im Querformat wird der Radar-Inhalt ausgeblendet und ein
819   Dreh-Hinweis gezeigt. */
820#sc-rotate-notice { display: none; }
821@media (orientation: landscape) {
822  #view-scanner #sc-status, #view-scanner #sc-btn-compass, #view-scanner #sc-ctrl,
823  #view-scanner #sc-radar-wrap, #view-scanner #sc-below-radar { display: none !important; }
824  #view-scanner #sc-rotate-notice {
825    display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center;
826    gap: 14px; padding: 24px; text-align: center; color: var(--sc-sub);
827  }
828  #sc-rotate-notice svg { width: 48px; height: 48px; color: var(--sc-scan); }
829}
830#sc-status { padding: 8px 16px; font-size: .82rem; color: var(--sc-sub); text-align: center; min-height: 20px; }
831#sc-status.ok   { color: var(--sc-scan); }
832#sc-status.warn { color: var(--sc-amber); }
833#sc-btn-compass {
834  display: none; margin: 0 16px 8px; background: rgba(255,176,32,.15); border: 1px solid var(--sc-amber);
835  color: var(--sc-amber); border-radius: 20px; padding: 6px 12px; font-size: .78rem; font-weight: 600; cursor: pointer;
836}
837#sc-ctrl { display: flex; justify-content: center; gap: 8px; padding: 0 16px 10px; flex-wrap: wrap; }
838.sc-ctrl-btn {
839  background: var(--sc-panel); border: 1px solid var(--sc-line); color: var(--sc-sub);
840  border-radius: 20px; padding: 6px 13px; font-size: .78rem; font-weight: 600; cursor: pointer;
841}
842.sc-ctrl-btn.active { background: var(--sc-scan2); border-color: var(--sc-scan); color: #04140b; }
843/* 14.08.2026 User-Wunsch ("möglichst viel vom freien Bereich nutzen"): 40vh/300px liess auf den
844   meisten Geräten viel ungenutzten Platz unter der Stationsliste - deutlich vergrössert.
845   #sc-below-radar bleibt flex:1 mit eigenem Scroll, verschwindet also nie komplett, schrumpft
846   nur passend zum verfügbaren Rest. */
847#sc-radar-wrap { flex: 0 0 auto; height: 58vh; max-height: 480px; display: flex; align-items: center; justify-content: center; padding: 6px 10px 4px; min-height: 0; }
848#sc-radar { display: block; touch-action: none; }
849#sc-below-radar { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; }
850#sc-blip-card {
851  display: none; margin: 0 16px 8px; background: var(--sc-panel); border: 1px solid var(--sc-scan2);
852  border-radius: 12px; padding: 10px 14px; font-size: .85rem;
853}
854#sc-blip-card .name { font-weight: 700; color: var(--sc-text); margin-bottom: 2px; }
855#sc-blip-card .meta { color: var(--sc-sub); font-size: .78rem; }
856#sc-blip-card .oez { color: var(--sc-sub); font-size: .78rem; margin-top: 4px; }
857#sc-blip-card .price { float: right; font-weight: 700; font-size: 1rem; color: var(--sc-scan); }
858#sc-blip-card .nav-link {
859  display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px;
860  background: var(--sc-scan2); color: #04140b; text-decoration: none; font-weight: 700;
861  font-size: .82rem; border-radius: 9px; padding: 9px;
862}
863#sc-sector-list { padding: 0 16px calc(var(--safe-b) + 14px); }
864#sc-sector-list .hdr { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--sc-sub); margin: 4px 0 6px; }
865.sc-row {
866  display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 6px;
867  background: var(--sc-panel); border: 1px solid var(--sc-line); border-radius: 10px; font-size: .82rem;
868}
869.sc-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
870.sc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--sc-text); }
871.sc-dist { color: var(--sc-sub); font-size: .76rem; flex-shrink: 0; }
872.sc-price { font-weight: 700; flex-shrink: 0; min-width: 58px; text-align: right; color: var(--sc-text); }
873#sc-sector-empty { color: var(--sc-sub); font-size: .82rem; text-align: center; padding: 18px 10px; }
874</style>
875<script>
876// 27.07.26: Theme so frueh wie moeglich (noch vor dem restlichen App-Script weiter unten)
877// setzen, damit bei "Dunkel" bzw. "Auto"+dunklem System kein kurzes Aufblitzen der hellen
878// Seite entsteht. Der Rest der Theme-Logik (Umschalter im Profil) folgt im Haupt-Script.
879(function () {
880  const t = localStorage.getItem('gasdb_theme') || 'auto';
881  const eff = t === 'auto' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : t;
882  document.documentElement.setAttribute('data-theme', eff);
883})();
884</script>
884
885</head>
886<body>
887<div id="ptr-indicator" class="ptr-indicator">
888  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
889</div>
890<!-- 11.09.2026: Update-Hinweis-Banner, siehe Kommentar zu $this_version_boot oben - erscheint nur,
891     wenn checkForUpdate() eine abweichende Server-Version findet, Klick laedt die Seite neu. -->
892<div id="update-banner" onclick="location.reload()" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;background:var(--green);color:#fff;text-align:center;font-size:.85rem;font-weight:600;padding:10px 12px;cursor:pointer">
893  🔄 Neue Version verfügbar – zum Aktualisieren tippen
894</div>
895<div id="app">
896
897  <!-- Header -->
898  <div id="header">
899    <!-- 28.08.2026 (User-Fund: "ein Klick auf Suche (Lupe) und Home (Haus) loesen das gleiche
900         aus, damit koennte man die Lupe im Menue einsparen" - Logo-Icon und der "Suche"-Button
901         im Bottom-Nav riefen beide identisch showView('home') auf. User-Idee: statt den
902         Bottom-Nav-Button ersatzlos zu streichen, das Logo-Icon selbst durch eine Lupe ersetzen
903         - passt semantisch ohnehin besser (fuehrt zur Suche, nicht zu einem Dashboard), macht
904         die Redundanz sichtbar auf und spart Platz in der Fussleiste (dort waren zuletzt 7
905         Buttons, siehe Bottom-Nav-Overflow-Fix). Logo-Icons in der Kopfzeile brauchen anders
906         als Tab-Buttons keine eigene Aktiv-Markierung. -->
907    <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" onclick="showView('home')" style="cursor:pointer">
908      <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
909    </svg>
910    <h1>Gas&shy;Tankstellen</h1>
911    <!-- 11.08.2026 User-Wunsch: dauerhafter, aber dezenter Hinweis bei fehlender Verbindung
912         (nicht nur ein einmaliger Toast) - User-Feedback zur ersten Version (volle Breite ganz
913         oben): "zu weit oben, da haben Handys idR andere Sachen anzuzeigen" - jetzt als kleiner,
914         gedrehter Stempel zwischen Titel und Versions-Badge. Siehe
915         window.addEventListener('online'/'offline', ...) unten. -->
916    <span id="offline-badge" style="display:none;transform:rotate(-25deg);color:#c0392b;border:1.5px solid #c0392b;border-radius:4px;padding:0 4px;font-size:.62rem;font-weight:800;letter-spacing:.03em;margin-right:4px">OFFLINE</span>
917    <!-- 11.09.2026 (User-Wunsch: "rechts oben in der PWA sieht man 'LPG * CNG', lass uns genau
918         DAS als Umschalter für die Gasart nehmen und damit Platz auf dem Display sparen. Die
919         aktive Gasart soll grösser und grün angezeigt werden. Reihenfolge bleibt wie sie ist")
920         - ersetzt die vormals rein dekorative "LPG · CNG"-Anzeige UND die separate Mode-Tab-Zeile
921         (volle Breite unter der Suchleiste) durch EIN kompaktes Element in der Kopfzeile. id/
922         class/data-mode bleiben absichtlich identisch zur vorherigen #mode-bar/.mode-btn-Struktur
923         - dadurch funktioniert die gesamte bestehende setMode()/Sichtbarkeits-Logik (Route-
924         Ansicht ausblenden, Gasart-Sperre bei Usern mit festem Profil-Gas) unveraendert weiter,
925         ohne dass an den mehreren Stellen, die #mode-bar ein-/ausblenden, etwas geaendert werden
926         musste. -->
927    <div id="mode-bar">
928      <button class="mode-btn active" data-mode="lpg">LPG</button>
929      <span id="mode-switch" aria-hidden="true"><span id="mode-switch-thumb"></span></span>
930      <button class="mode-btn" data-mode="cng">CNG</button>
931    </div>
932  </div>
933
934  <!-- Suchleiste -->
935  <div id="search-bar">
936    <input id="search-input" type="search" placeholder="PLZ, Ort, Kennzeichen oder Autobahn (A2) …" autocomplete="off" enterkeyhint="search">
937    <button id="btn-search-filter" type="button" aria-label="Filter" title="Filter">🔽 <span id="sf-count"></span></button>
938    <button id="btn-search" type="button" aria-label="Suchen"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <span class="btn-search-label">Suchen</span></button>
939  </div>
940  <div id="search-filter-panel">
941    <label class="sf-row"><input type="checkbox" class="sf-cb" value="ec"> 💳 EC/GiroCard</label>
942    <label class="sf-row"><input type="checkbox" class="sf-cb" value="kk"> 💳 Kreditkarte</label>
943    <label class="sf-row"><input type="checkbox" class="sf-cb" value="d7"> 📅 täglich geöffnet</label>
944    <label class="sf-row"><input type="checkbox" class="sf-cb" value="h24"> 🕐 24h geöffnet</label>
945    <label class="sf-row"><input type="checkbox" class="sf-cb" value="bd"> ⛽ auch Benzin</label>
946    <label class="sf-row"><input type="checkbox" class="sf-cb" value="lkw"> 🚚 LKW-geeignet</label>
947  </div>
948
949  <!-- View: Home -->
950  <!-- 29.08.2026 (Code-Qualitaet, User-Wunsch: "PWA in Includes aufteilen wie beim Tankbuch") -
951       reine Markup-Extraktion, siehe mobile/home.inc.php. -->
952  <!-- mobile/home.inc.php — Home/Suche-View der PWA
953     29.08.2026 (Code-Qualitaet, User-Wunsch: "PWA zur Verbesserung der Code-Qualitaet in
954     Includes aufteilen, so wie beim Tankbuch") - reine Markup-Extraktion aus mobile/index.php,
955     keine Verhaltensaenderung. Nutzt Variablen aus dem einschliessenden Scope
956     ($_pwa_db_import_time), da PHP include() im Scope der aufrufenden Datei ausgefuehrt wird. -->
957<div id="view-home">
958  <div id="home-center">
959    <div style="font-size:3.5rem">⛽</div>
960    <div style="font-size:.7rem;color:var(--sub);margin-bottom:-8px">26.10.01-4</div>
961        <button id="btn-open-help" type="button" style="background:none;border:none;color:var(--blue);font-size:.75rem;font-weight:600;margin-bottom:8px;
961cursor:pointer;padding:2px 8px">❓ Tipps zur Bedienung</button>
962    <h2 style="margin-top:12px;font-size:1.2rem">LPG &amp; CNG in der Nähe</h2>
963    <p>Finde Flüssiggastankstellen<br>und Erdgastankstellen in deiner Nähe.</p>
964    <div style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap">
965      <button id="btn-gps" style="position:relative">
966        <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
967          <circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
968          <path d="M12 8a4 4 0 100 8 4 4 0 000-8z" opacity=".3"/>
969        </svg>
970        Meinen Standort nutzen
971      </button>
972      <select id="gps-radius" title="Suchradius">
973        <option value="2.5">2,5 km</option>
974        <option value="5">5 km</option>
975        <option value="7.5">7,5 km</option>
976        <option value="10">10 km</option>
977        <option value="20">20 km</option>
978        <option value="30">30 km</option>
979      </select>
980    </div>
981  </div>
982  <div id="fav-tabs" style="display:none">
983    <button class="fav-tab active" data-tab="local" title="Lokale Favoriten">♥</button>
984    <button class="fav-tab" data-tab="profile" title="Profil-Favoriten">★</button>
985  </div>
986  <div id="fav-section"></div>
987</div>
988
989  <!-- View: Liste -->
990  <!-- mobile/list.inc.php — Listen-View der PWA
991     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
992     Verhaltensaenderung. -->
993<div id="view-list">
994  <div id="list-header">
995    <span id="list-title">Ergebnisse</span>
996    <span style="display:flex;align-items:center;gap:8px">
997      <span id="list-count"></span>
998      <button id="btn-list-print" aria-label="Liste drucken" title="Liste drucken">🖨️</button>
999    </span>
1000  </div>
1001  <div id="list-items"></div>
1002</div>
1003
1004  <!-- View: Detail -->
1005  <!-- mobile/detail.inc.php — Detail-View der PWA
1006     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1007     Verhaltensaenderung. -->
1008<div id="view-detail">
1009  <div id="detail-map"></div>
1010  <div id="detail-body">
1011    <div id="detail-name"></div>
1012    <div id="detail-addr"></div>
1013    <div class="feat-badges" id="detail-badges" style="margin-top:6px"></div>
1014    <div class="detail-prices" id="detail-prices"></div>
1015    <div id="detail-infos"></div>
1016    <div class="detail-actions" id="detail-actions"></div>
1017  </div>
1018</div>
1019
1020  <!-- View: Karte -->
1021  <!-- mobile/map.inc.php — Karten-View der PWA
1022     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1023     Verhaltensaenderung. -->
1024<div id="view-map">
1025  <div id="full-map"></div>
1026  <div id="map-toast"></div>
1027  <button id="map-large-toggle" type="button" title="Große Ansicht (weniger Tankstellen, besser lesbar)">🔍</button>
1028  <button id="map-auto-toggle" type="button" title="Auto-Modus (Standort + Karte alle 5 Minuten automatisch aktualisieren)">🚗</button>
1029  <div id="map-radius-ctrl">
1030    <button id="map-radius-minus" type="button" title="Kleinerer Umkreis">−</button>
1031    <span id="map-radius-label"></span>
1032    <button id="map-radius-plus" type="button" title="Größerer Umkreis">+</button>
1033  </div>
1034</div>
1035
1036  <!-- View: Scanner (27.07.26, Integration von mobile/scanner.php, siehe project_pwa_scanner) -->
1037  <!-- mobile/scanner.inc.php — Scanner-View der PWA (27.07.26, Integration von mobile/scanner.php,
1038     siehe project_pwa_scanner)
1039     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1040     Verhaltensaenderung. -->
1041<div id="view-scanner">
1042  <div id="sc-status">Warte auf Standort…</div>
1043  <button id="sc-btn-compass">🧭 Kompass aktivieren</button>
1044  <div id="sc-ctrl">
1045    <button class="sc-ctrl-btn" data-sc-range="2">2 km</button>
1046    <button class="sc-ctrl-btn active" data-sc-range="5">5 km</button>
1047    <button class="sc-ctrl-btn" data-sc-range="10">10 km</button>
1048    <button class="sc-ctrl-btn" data-sc-range="20">20 km</button>
1049    <button class="sc-ctrl-btn" data-sc-range="40">40 km</button>
1050  </div>
1051  <div id="sc-radar-wrap"><canvas id="sc-radar" width="340" height="240"></canvas></div>
1052  <div id="sc-below-radar">
1053    <div id="sc-blip-card"></div>
1054    <div id="sc-sector-list">
1055      <div class="hdr">Im Sektor voraus (±<span id="sc-sector-deg">60</span>°)</div>
1056      <div id="sc-sector-rows"></div>
1057      <div id="sc-sector-empty" style="display:none">Keine Station im Sektor voraus im gewählten Umkreis.</div>
1058    </div>
1059  </div>
1060  <div id="sc-rotate-notice">
1061    <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1062      <rect x="7" y="2" width="10" height="20" rx="2"/>
1063      <path d="M3 8 a9 9 0 0 1 6-6" stroke-dasharray="1.5 2.5"/>
1064      <path d="M3 8 l0 -3.5 M3 8 l3.2 -0.6" stroke-dasharray="none"/>
1065    </svg>
1066    <div>Bitte das Gerät hochkant halten — der Scanner ist auf Hochformat ausgelegt.</div>
1067  </div>
1068</div>
1069
1070  <!-- View: Hilfe -->
1071  <!-- mobile/help.inc.php — Hilfe-View der PWA ("Tipps zur Bedienung")
1072     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1073     Verhaltensaenderung. -->
1074<div id="view-help" style="display:none">
1075  <div style="width:100%;max-width:420px">
1076    <button id="btn-close-help" type="button" style="background:none;border:none;color:var(--blue);font-size:.85rem;font-weight:600;margin-bottom:10px;cursor:pointer;padding:4px 0">← Zurück</button>
1077    <div class="p-card">
1078      <h2 style="margin-bottom:14px">❓ Tipps zur Bedienung</h2>
1079
1080      <div class="p-hint-row" style="font-weight:600;color:var(--text)">🔍 Suche</div>
1081      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1082        Gib PLZ, Ort, ein KFZ-Kennzeichen-Kürzel (z. B. "BI") oder eine Autobahn (z. B. "A2") ein.
1083        Bei Adressen (mit Komma oder Straßenwort wie "Straße"/"Weg") wird automatisch die genaue
1084        Adresse gesucht statt nur der Ort.
1085      </p>
1086
1087      <div class="p-hint-row" style="font-weight:600;color:var(--text)">❗ Direkt auf der Karte suchen</div>
1088      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1089        Mit einem <code>!</code> vor dem Suchbegriff (z. B. <code>!Soest</code>) wird direkt eine
1090        echte Karten-/Adresssuche ausgelöst, statt zuerst nur nach PLZ/Ort in unserer Tankstellen-
1091        Datenbank zu suchen. Hilfreich, wenn ein Ortsname dort nicht eindeutig gefunden wird.
1092      </p>
1093
1094      <div class="p-hint-row" style="font-weight:600;color:var(--text)">🌍 Suche auf ein Land eingrenzen</div>
1095      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1096        Manche Ortsnamen gibt es in mehreren Ländern (z. B. "Soest" in Deutschland <em>und</em>
1097        in den Niederlanden). Mit dem Präfix <code>XX-Ort</code> zwingst du die Suche auf ein
1098        bestimmtes Land, z. B. <code>!NL-Soest</code> oder <code>!AT-Graz</code> (am besten mit
1099        <code>!</code> kombiniert). Unterstützt: DE, AT, CH, NL, BE, FR, PL, CZ, LU.
1100      </p>
1101
1102      <div class="p-hint-row" style="font-weight:600;color:var(--text)">📍 Standort verwenden</div>
1103      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1104        Der Standort-Button neben dem Suchfeld ermittelt deine aktuelle Position per GPS und
1105        zeigt Tankstellen in deiner Nähe – dein Browser fragt dabei einmalig um Erlaubnis.
1106      </p>
1107
1108      <div class="p-hint-row" style="font-weight:600;color:var(--text)">👆 Schnellzugriff per Long Press</div>
1109      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1110        Länger auf "Liste" oder "Karte" unten im Menü drücken startet direkt eine Umkreissuche
1111        an deinem aktuellen GPS-Standort – ohne Umweg über das Suchfeld. Die Ergebnisliste bzw.
1112        -karte nutzt dabei den vollen Bildschirm. Der kleine <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:#e67e22;vertical-align:middle"></span>
1113        Punkt am jeweiligen Symbol zeigt, wo Long Press verfügbar ist. Länger direkt
1114        <em>innerhalb</em> der Karte auf eine Stelle drücken löst stattdessen eine neue Suche
1115        genau an diesem Ort aus.
1116      </p>
1117
1118      <div class="p-hint-row" style="font-weight:600;color:var(--text)">⛽ LPG / CNG umschalten</div>
1119      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1120        <!-- 11.09.2026: Umschalter von einer eigenen Tab-Zeile in die Kopfzeile verlegt (User-
1121             Wunsch: "genau DAS als Umschalter nehmen und damit Platz sparen") - Hilfetext
1122             entsprechend angepasst, die groß+grün hervorgehobene Gasart oben rechts tippen. -->
1123        Oben rechts in der Kopfzeile antippen, um zwischen Autogas (LPG) und Erdgas (CNG) zu
1124        wechseln – die aktive Gasart ist grün und größer dargestellt. Ergebnisliste und Karte
1125        passen sich sofort an.
1126      </p>
1127
1128      <div class="p-hint-row" style="font-weight:600;color:var(--text)">🗺️ Karte</div>
1129      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1130        In der Kartenansicht siehst du alle Tankstellen im aktuellen Suchbereich auf einen Blick.
1131        Tippe auf einen Marker für Details, oder wechsle unten im Menü auf "Liste" zurück zur Listenansicht.
1132        Länger auf eine beliebige Stelle der Karte drücken (Long Press) startet eine neue Umkreissuche
1133        genau an dieser Position.
1134      </p>
1135
1136      <div class="p-hint-row" style="font-weight:600;color:var(--text)">📡 Scanner</div>
1137      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1138        Der Scanner zeigt Tankstellen in deiner Nähe live als Radar an, ausgerichtet nach
1139        Entfernung und Richtung – praktisch während der Fahrt, ohne die Liste durchsuchen zu
1140        müssen. Nutzt kontinuierliches GPS-Tracking, daher etwas höherer Akkuverbrauch als in
1141        den anderen Ansichten.
1142      </p>
1143
1144      <div class="p-hint-row" style="font-weight:600;color:var(--text)">🧭 Routenplaner</div>
1145      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1146        Plant eine Route mit Start, Ziel und optionalen Zwischenstopps – Tankstellen entlang der
1147        Strecke werden automatisch vorgeschlagen.
1148      </p>
1149
1150      <!-- 29.08.2026 (User-Frage: "ist der Hilfe-Text noch aktuell?") - Tankbuch fehlte
1151           komplett, obwohl es inzwischen ein vollwertiges Feature mit eigenem Menuepunkt ist. -->
1152      <div class="p-hint-row" style="font-weight:600;color:var(--text)">⛽ Tankbuch</div>
1153      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1154        Angemeldet (Profil-Tab) erfasst du hier Tankdatum, Km-Stand, Liter und optional den
1155        Gesamtpreis – daraus berechnet die App deinen Verbrauch je Tankung und einen
1156        langfristigen Durchschnitt, auch für mehrere Fahrzeuge getrennt. Gelegentliches
1157        Benzin-Tanken (z. B. bei Erdgas-/Autogasfahrzeugen mit Reservetank) wird separat als
1158        Nebenstatistik geführt. Vollständige Verwaltung (mehrere Fahrzeuge anlegen, Einträge
1159        bearbeiten) gibt es auf der Website – die App zeigt hier bewusst nur die letzten
1160        Tankungen zur schnellen Eingabe unterwegs.
1161      </p>
1162
1163      <div class="p-hint-row" style="font-weight:600;color:var(--text)">♥ Lokale Favoriten</div>
1164      <p style="font-size:.85rem;color:var(--sub);margin-bottom:12px">
1165        Ohne Anmeldung als Favorit markierte Tankstellen bleiben nur auf diesem Gerät gespeichert
1166        (im Browser) – kein Login nötig, aber auch keine Synchronisierung zwischen Geräten.
1167      </p>
1168
1169      <div class="p-hint-row" style="font-weight:600;color:var(--text)">★ Profil-Favoriten</div>
1170      <p style="font-size:.85rem;color:var(--sub);margin-bottom:0">
1171        Angemeldet (Profil-Tab) kannst du Tankstellen als Favorit markieren – sie werden geräteübergreifend
1172        synchronisiert und deine Preismeldungen deinem Konto zugeordnet. Dasselbe ★ wie auf der Website.
1173      </p>
1174    </div>
1175    <div class="p-card" style="text-align:center;font-size:.82rem">
1176      <a href="/impressum.php" target="_blank" rel="noopener" style="color:var(--blue);text-decoration:none">Impressum</a>
1177      &nbsp;·&nbsp;
1178      <a href="/datenschutzerklaerung.php" target="_blank" rel="noopener" style="color:var(--blue);text-decoration:none">Datenschutzerklärung</a>
1179    </div>
1180  </div>
1181</div>
1182
1183  <!-- View: Route -->
1184  <!-- mobile/route.inc.php — Routenplaner-View der PWA
1185     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1186     Verhaltensaenderung. -->
1187<div id="view-route">
1188  <!-- Formular -->
1189  <div id="route-form-panel">
1190    <div id="route-history-row">
1191      <div class="rh-label">🕐 Letzte Routen</div>
1192      <div class="rh-chips" id="rh-chips"></div>
1193    </div>
1194    <div class="route-field">
1195      <label>🚦 Start</label>
1196      <div class="route-input-wrap">
1197        <input id="route-start-input" type="text" placeholder="Adresse eingeben…" autocomplete="off">
1198        <button class="btn-gps-inline" id="btn-gps-route-inline" title="Meinen Standort als Start">📍 GPS</button>
1199      </div>
1200      <div id="route-start-sugg" class="route-suggestions"></div>
1201    </div>
1202    <div id="route-wp-list" style="display:none"></div>
1203    <button id="btn-add-wp">+ Zwischenstopp</button>
1204    <div class="route-field">
1205      <label>🏁 Ziel</label>
1206      <input id="route-end-input" type="text" placeholder="Zieladresse eingeben…" autocomplete="off">
1207      <div id="route-end-sugg" class="route-suggestions"></div>
1208    </div>
1209    <div class="route-mode-row">
1210      <button class="route-mode-btn active" data-rmode="lpg">⛽ LPG</button>
1211      <button class="route-mode-btn" data-rmode="cng">🔵 CNG</button>
1212
1213    </div>
1214    <!-- 18.09.2026 (User: "in der PWA sehe ich den Filter noch nicht") - gleiche Merkmals-Filter wie im
1215         Desktop-Routenplaner, serverseitig ueber route.php "filters". -->
1216    <button id="btn-route-filter-toggle" type="button" class="route-filter-toggle">🔽 Filter <span id="route-filter-count"></span></button>
1217    <div id="route-filter-box" style="display:none">
1218      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="ec"> 💳 nur mit EC/GiroCard</label>
1219      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="kk"> 💳 nur mit Kreditkarte (Mastercard/Visa)</label>
1220      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="d7"> 📅 nur täglich geöffnet</label>
1221      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="h24"> 🕐 nur 24h geöffnet</label>
1222      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="bd"> ⛽ nur auch mit Benzin</label>
1223      <label class="route-filter-row"><input type="checkbox" class="route-filter-cb" value="lkw"> 🚚 nur auch LKW-geeignet</label>
1224    </div>
1225    <div class="route-two-col">
1226      <div class="route-field">
1227        <label>🔋 Reichweite (km)</label>
1228        <input id="route-range" type="number" value="350" min="50" max="800" step="10">
1229      </div>
1230      <div class="route-field">
1231        <label>↔️ Korridor (km)</label>
1232        <input id="route-corridor" type="number" value="15" min="5" max="30" step="5">
1233      </div>
1234    </div>
1235    <div class="route-reserve-hint">⚠️ 50 km Reserve wird automatisch abgezogen</div>
1236    <div class="route-avoid-row">
1237      <span>Autobahn vermeiden</span>
1238      <label class="route-toggle">
1239        <input type="checkbox" id="route-avoid-motorway">
1240        <span class="route-toggle-slider"></span>
1241      </label>
1242    </div>
1243    <button id="btn-route-calc">Route berechnen 🔍</button>
1244  </div>
1245  <!-- Ergebnis -->
1246  <div id="route-result-panel">
1247    <div id="route-map-container"></div>
1248    <div id="route-result-list">
1249      <div id="route-error-msg" style="display:none"></div>
1250      <div id="route-info-bar"></div>
1251      <div id="route-stops"></div>
1252      <div class="route-all-toggle" id="route-all-toggle">▶ Alle Stationen im Korridor</div>
1253      <div id="route-all-list"></div>
1254      <button id="btn-route-reset">← Neue Route</button>
1255    </div>
1256  </div>
1257</div>
1258
1259  <!-- View: Profil -->
1260  <!-- mobile/profile.inc.php — Profil-View der PWA (Login/Logout, Darstellung, Gas-Typ, Spenden,
1261     Admin-Kennzahlen)
1262     29.08.2026 (Code-Qualitaet): reine Markup-Extraktion aus mobile/index.php, keine
1263     Verhaltensaenderung. -->
1264<div id="view-profile">
1265  <!-- 27.07.26 User-Wunsch: manueller Light/Dark/Auto-Umschalter im Profil, uebersteuert die
1266       Systemeinstellung (siehe applyTheme() weiter unten). Immer sichtbar, unabhaengig vom
1267       Login-Status. -->
1268  <div class="p-card" style="width:100%;max-width:420px">
1269    <div class="p-hint-row" style="font-weight:600;color:var(--text)">🎨 Darstellung</div>
1270    <div class="gas-pick-row">
1271      <button class="gas-pick-btn" id="theme-light" data-theme-choice="light">☀️ Hell</button>
1272      <button class="gas-pick-btn" id="theme-dark" data-theme-choice="dark">🌙 Dunkel</button>
1273      <button class="gas-pick-btn" id="theme-auto" data-theme-choice="auto">📱 Auto</button>
1274    </div>
1275  </div>
1276  <div id="profile-login-form">
1277    <div class="p-card">
1278      <h2>Anmelden</h2>
1279      <div class="p-field">
1280        <label>Benutzername</label>
1281        <input type="text" id="profile-username" autocomplete="username" placeholder="Benutzername">
1282      </div>
1283      <div class="p-field">
1284        <label>Passwort</label>
1285        <input type="password" id="profile-password" autocomplete="current-password" placeholder="Passwort">
1286      </div>
1287      <div class="p-field" id="profile-otp-field" style="display:none;">
1288        <label>2FA-Code</label>
1289        <input type="text" id="profile-otp-code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="123456">
1290      </div>
1291      <div class="p-error" id="profile-error"></div>
1292      <div class="login-btn-row">
1293        <button class="btn-login" id="btn-do-login">Anmelden</button>
1294        <button class="btn-oauth-icon btn-apple" id="btn-apple-signin" title="Mit Apple anmelden" aria-label="Mit Apple anmelden">
1295          <svg width="20" height="20" viewBox="0 0 814 1000" fill="currentColor"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105-43.2-150.3-109.5C82.7 763.3 31.4 674.7 19.7 603.5c-2.6-15.7-3.9-31.3-3.9-46.7 0-168.3 110.8-257.2 219.7-257.2 59.2 0 108.5 39.2 145.4 39.2 35.4 0 90.5-41.5 158.1-41.5 28.4 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>
1296        </button>
1297                <button class="btn-oauth-icon btn-google" id="btn-google-signin" title="Mit Google anmelden" aria-label="Mit Google anmelden">
1298          <svg width="20" height="20" viewBox="0 0 48 48"><path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v8.5h12.7c-.6 3-2.3 5.5-4.8 7.2v6h7.7c4.5-4.2 7.1-10.3 7.1-17.2z"/><path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.7-6c-2.1 1.4-4.9 2.3-8.2 2.3-6.3 0-11.6-4.2-13.5-9.9H2.5v6.2C6.5 41.8 14.7 47 24 47z"/><path fill="#FBBC05" d="M10.5 28.6c-.5-1.4-.8-2.9-.8-4.6s.3-3.2.8-4.6v-6.2H2.5C.9 16.6 0 20.2 0 24s.9 7.4 2.5 10.8l8-6.2z"/><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9.1 3.6l6.8-6.8C35.9 2.4 30.5 0 24 0 14.7 0 6.5 5.2 2.5 13.2l8 6.2C12.4 13.7 17.7 9.5 24 9.5z"/></svg>
1299        </button>
1300              </div>
1301    </div>
1302    <!-- Link-Verknüpfungs-Dialog (erscheint wenn E-Mail bereits existiert) -->
1303    <div id="oauth-link-box" style="display:none" class="p-card">
1304      <p id="oauth-link-msg" style="font-size:.9rem;margin-bottom:10px;"></p>
1305      <div class="p-field">
1306        <label>Passwort bestätigen</label>
1307        <input type="password" id="oauth-link-pw" placeholder="Passwort">
1308      </div>
1309      <div class="p-error" id="oauth-link-error"></div>
1310      <button class="btn-login" id="btn-oauth-link">Verknüpfen & anmelden</button>
1311      <button class="btn-login" id="btn-oauth-link-cancel" style="background:var(--bg);color:var(--text);border:2px solid var(--border);margin-top:8px">Abbrechen</button>
1312    </div>
1313  </div>
1314  <div id="profile-loggedin" style="display:none">
1315    <div class="p-card">
1316      <div class="p-user-row">
1317        <div class="p-avatar" id="profile-avatar">?</div>
1318        <div>
1319          <!-- 28.08.2026 (User-Wunsch: "primär den Usernamen (Login-Namen) und darunter klein
1320               den Real-name") - vorher stand vorname (Realname) oben, Username war gar nicht
1321               sichtbar; jetzt umgekehrt: Login-Name gross, Realname klein darunter. -->
1322          <div id="profile-name" style="font-weight:700;font-size:1rem"></div>
1323          <div id="profile-realname" style="font-size:.78rem;color:var(--sub)"></div>
1324        </div>
1325      </div>
1326      <button class="btn-logout" id="btn-do-logout">Abmelden</button>
1327    </div>
1328    <div class="p-card">
1329      <div class="p-hint-row" style="font-weight:600;color:var(--text)">⛽ Gas-Typ einstellen</div>
1330      <div class="gas-pick-row">
1331        <button class="gas-pick-btn" id="pick-lpg" data-gas="lpg">⛽ LPG</button>
1332        <button class="gas-pick-btn" id="pick-cng" data-gas="cng">🔵 CNG</button>
1333      </div>
1334      <div class="p-hint-row">♥ Favoriten merken — auf allen Geräten synchron</div>
1335      <div class="p-hint-row">✅ Preismeldungen werden deinem Konto zugeordnet</div>
1336      <!-- 05.09.2026 (User-Wunsch: "im Profil nach 'Preismeldungen werden'... ein
1337           '-> Schnellupdate-Link' hinzufügen mit dem /mobile/q") - Zugang zur Home-Bildschirm-
1338           Einrichtungsseite fuer den "an der Tankstelle vorbeifahren"-Anwendungsfall. Mehrere
1339           Anlaeufe, den Sprung aus der bereits als Standalone laufenden Haupt-App heraus direkt
1340           in echtes Safari zu erzwingen (x-safari-https://-Praefix, target="_blank",
1341           Redirect-auf-sich-selbst, echter Cross-Origin-Redirect ueber die bare-domain) zeigten
1342           beim Live-Test am echten iPhone alle KEINEN zuverlaessigen Erfolg - iOS' Verhalten fuer
1343           target="_blank" aus einer Standalone-PWA heraus ist offenbar grundsaetzlich flakey
1344           (funktionierte einmal zufaellig, beim naechsten identischen Test nicht mehr, unabhaengig
1345           von allen versuchten Tricks). User-Idee als 100% zuverlaessige Alternative: "kannst Du
1346           per Klick den Link in die Zwischenablage kopieren und einen Hinweis geben, den in
1347           Safari einzufuegen?" - kein Verlassen-des-Standalone-Containers mehr noetig, der User
1348           oeffnet Safari selbst manuell und fuegt die kopierte URL ein. Aus einem Link ein
1349           `<button>` gemacht (kein href mehr, daher kein target="_blank"-Verhalten mehr relevant);
1350           der Klick-Handler (per navigator.clipboard.writeText(), siehe mobile/index.php) kopiert
1351           die vollstaendige URL und zeigt per showToast() die Anleitung. -->
1352            <div class="p-hint-row"><button type="button" id="btn-qgo-copy" data-qgo-url="https://gas-tankstellen.de/mobile/q" style="background:none;border:none;padding:0;font:inherit;color:var(--blue);font-weight:600;cursor:pointer">→ Schnellupdate-Link kopieren (zum Home-Bildschirm hinzufügen)</button></div>
1353    </div>
1354  </div>
1355  <div id="profile-donate-card" style="width:100%;max-width:420px;margin-top:8px">
1356    <div class="p-card" style="text-align:center">
1357      <div style="font-weight:700;font-size:.95rem;margin-bottom:10px">☕️ Projekt unterstützen</div>
1358      <p style="font-size:.82rem;color:var(--sub);margin-bottom:14px">Diese App ist kostenlos und werbefrei.
1359        Über eine kleine Spende freue ich mich sehr!</p>
1360      <a href="https://www.buymeacoffee.com/7opghqhhsy" target="_blank" rel="noopener"
1361         style="display:inline-block;background:#FFDD00;color:#000;font-weight:700;
1362         border-radius:10px;padding:11px 22px;font-size:.95rem;text-decoration:none;
1363         border:2px solid #000">☕ Buy me a coffee</a>
1364      &nbsp;&nbsp;
1365      <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=info%40neumann%2dedv%2ede&undefined_quantity=1&item_name=Unterst%C3%BCtzung%20der%20www%2egas%2dtankstellen%2einfo&item_number=60002&amount=5%2e00&no_shipping=1&return=http%3a%2f%2fwww%2egas%2dtankstellen%2einfo%2finfo_pp_danke%2ephp&no_note=1&currency_code=EUR&lc=DE&bn=PP%2dBuyNowBF&charset=UTF%2d8"
1366         target="_blank" rel="noopener" title="Per PayPal spenden"
1367         style="display:inline-block;vertical-align:middle"><img src="/mobile/paypal5.gif" alt="PayPal spenden" style="height:31px;border:0"></a>
1368    </div>
1369  </div>
1370  <div id="profile-admin-card" style="width:100%;max-width:420px;margin-top:8px;display:none">
1371    <div class="p-card">
1372      <div style="font-weight:700;font-size:.95rem;margin-bottom:10px">📊 Admin-Kennzahlen</div>
1373      <div id="admin-stats-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:.82rem;">
1374        <div style="text-align:center"><div id="stat-pageviews" style="font-size:1.3rem;font-weight:700">–</div>Seitenaufrufe (24h)</div>
1375        <div style="text-align:center"><div id="stat-apicalls" style="font-size:1.3rem;font-weight:700">–</div>API-Calls (24h)</div>
1376        <div style="text-align:center"><div id="stat-activeusers" style="font-size:1.3rem;font-weight:700">–</div>Aktive User (30T)</div>
1377        <div style="text-align:center"><div id="stat-loginpct" style="font-size:1.3rem;font-weight:700">–</div>Angemeldet (24h)</div>
1378      </div>
1379    </div>
1380  </div>
1381</div>
1382
1383  <!-- mobile/tankbuch.inc.php — Tankbuch-View der PWA
1384     28.08.2026 (PLAN Tankbuch): ausgelagert statt direkt in mobile/index.php (das bereits weit
1385     ueber dem 500-Zeilen-Projektlimit liegt), per include() an der Stelle der anderen View-Divs
1386     eingebunden. Nutzt dieselben Element-IDs wie tankbuch.php (
1386Desktop), damit js/tankbuch.js
1387     ohne Anpassung in beiden Kontexten funktioniert. Login-Gate hier ist nur kosmetisch
1388     (siehe #tankbuch-login-hint) - der eigentliche Schutz liegt in mobile/api/tankbuch.php. -->
1389<div id="view-tankbuch">
1390
1391	<div id="tankbuch-login-hint" class="p-card" style="display:none">
1392		<div class="p-hint-row">🔒 Bitte im Profil anmelden, um das Tankbuch zu nutzen.</div>
1393	</div>
1394
1395	<div id="tankbuch-content" style="display:none;width:100%;max-width:420px">
1396		<!-- 28.08.2026 (Zweitfahrzeug-Erweiterung, User-Wunsch: "Auswahl bei der Eingabe, Bezeichner
1397		     nur auf der Website anpassbar") - reine Auswahl-Dropdown, keine Verwaltungs-Buttons hier,
1398		     um das PWA-Interface minimal zu halten. Wird von js/tankbuch.js nur eingeblendet, wenn
1399		     mehr als ein Fahrzeug existiert. -->
1400		<div class="p-card" id="tb-fahrzeug-wrap" style="display:none">
1401			<div class="p-field" style="margin-bottom:0">
1402				<label>Fahrzeug</label>
1403				<select id="tb-fahrzeug"></select>
1404			</div>
1405		</div>
1406
1407		<div class="p-card">
1408			<form id="tb-form">
1409				<div class="p-field">
1410					<label>Tankdatum</label>
1411					<input type="date" id="tb-datum" required>
1412				</div>
1413				<div class="p-field">
1414					<label>Km-Stand</label>
1415					<!-- 28.08.2026 (User-Frage: "kann man die Eingabe von Preis und km eleganter
1416					     gestalten?") - type=number zeigt auf iOS ein Tastenfeld mit +/-/e und
1417					     unpassenden Spinner-Pfeilen; inputmode=numeric gibt das reine Ziffernblock,
1418					     Sanitizing per JS (siehe initTankbuch() in js/tankbuch.js) haelt nur Ziffern zu. -->
1419					<input type="text" inputmode="numeric" pattern="[0-9]*" id="tb-km" required>
1420				</div>
1421				<div class="p-field">
1422					<label>Liter</label>
1423					<!-- 28.08.2026 (User-Wunsch: "Literangabe doch bitte mit 2 Dezimalstellen") -
1424					     zurueckgerollt von ganzzahlig auf Dezimal-Tastenfeld wie Preis. -->
1425					<input type="text" inputmode="decimal" id="tb-liter" required>
1426				</div>
1427				<div class="p-field">
1428					<label>Preis gesamt (optional)</label>
1429					<div class="tb-prefix-wrap">
1430						<span class="tb-prefix">€</span>
1431						<input type="text" inputmode="decimal" id="tb-preis" placeholder="0,00">
1432					</div>
1433				</div>
1434				<!-- 28.08.2026 (Benzin-Nebenstatistik, User-Wunsch: "Default ist die gewaehlte
1435				     Gas-Art") - Gas vorausgewaehlt, Benzin ist die seltene Ausnahme. -->
1436				<div class="p-field">
1437					<label>Kraftstoff</label>
1438					<select id="tb-kraftstoff">
1439						<option value="gas" selected>Gas</option>
1440						<option value="benzin">Benzin</option>
1441					</select>
1442				</div>
1443				<!-- 28.08.2026 ("voll getankt"-Checkbox) - nur bei Kraftstoff=Benzin sichtbar,
1444				     siehe updateVollgetanktVisibility() in js/tankbuch.js. -->
1445				<div class="p-field" id="tb-vollgetankt-wrap" style="display:none">
1446					<label style="display:flex;align-items:center;gap:6px;font-weight:400;text-transform:none;letter-spacing:normal;cursor:pointer">
1447						<input type="checkbox" id="tb-vollgetankt" style="width:auto"> voll getankt
1448					</label>
1449				</div>
1450				<div class="p-field" style="display:flex;gap:8px">
1451					<button type="submit" id="tb-form-submit" class="gas-pick-btn">Hinzufügen</button>
1452					<button type="button" id="tb-form-cancel" class="gas-pick-btn" style="display:none">Abbrechen</button>
1453				</div>
1454			</form>
1455		</div>
1456
1457		<div class="p-card">
1458			<div id="tb-stats" class="p-hint-row"></div>
1459			<div id="tb-stats-benzin" class="p-hint-row" style="opacity:.7;font-size:.8rem"></div>
1460			<canvas id="tb-chart"></canvas>
1461		</div>
1462
1463		<div class="p-card">
1464			<div id="tb-list"></div>
1465		</div>
1466	</div>
1467
1468</div>
1469  
1469<script src="../js/tankbuch.js"></script>
1469
1470
1471  <!-- Bottom Nav -->
1472  <nav id="bottom-nav">
1473    <button class="nav-btn active has-longpress" data-view="list">
1474      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1475        <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
1476        <rect x="9" y="3" width="6" height="4" rx="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/>
1477      </svg>
1478      Liste
1479    </button>
1480    <button class="nav-btn has-longpress" data-view="map">
1481      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1482        <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
1483        <line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
1484      </svg>
1485      Karte
1486    </button>
1487    <button class="nav-btn" data-view="route">
1488      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1489        <circle cx="5" cy="18" r="2.5" stroke-width="2"/><circle cx="19" cy="6" r="2.5" stroke-width="2"/>
1490        <path d="M5 15.5V12c0-3 2-5 5-5h4c3 0 5-2 5-3.5"/>
1491      </svg>
1492      Route
1493    </button>
1494    <button class="nav-btn" data-view="scanner">
1495      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1496        <circle cx="12" cy="12" r="2.2"/>
1497        <path d="M12 12 L12 4" stroke-dasharray="1 2.5"/>
1498        <path d="M4.5 16.5A9 9 0 0 1 12 3" stroke-dasharray="1.5 3"/>
1499        <path d="M19.5 16.5A9 9 0 0 0 12 3" stroke-dasharray="1.5 3"/>
1500      </svg>
1501      Scanner
1502    </button>
1503    <button class="nav-btn" data-view="tankbuch">
1504      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1505        <path d="M14 3H6a1 1 0 00-1 1v16a1 1 0 001 1h9a1 1 0 001-1V9z"/>
1506        <path d="M15 3v5a1 1 0 001 1h5"/>
1507        <path d="M18 9v4a2 2 0 002 2v0a2 2 0 002-2V9l-2-3"/>
1508      </svg>
1509      Tankbuch
1510    </button>
1511    <button class="nav-btn" data-view="profile">
1512      <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
1513        <circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
1514      </svg>
1515      <span id="nav-profile-label">Profil</span>
1516    </button>
1517  </nav>
1518</div>
1519
1520<!-- Loader & Toast -->
1521<div id="loader" class="show"><div class="spinner"></div></div>
1522<div id="toast"></div>
1523
1524<!-- Preismeldungs-Sheet -->
1525<div id="price-sheet-overlay">
1526  <div id="price-sheet">
1527    <button class="price-sheet-btn" id="ps-btn-confirm"></button>
1528    <!-- 04.09.2026 (User: "der Stepper ist gut gelöst. Dort ist aber die Schaltfläche 'Preis
1529         aktualisieren' überflüssig") - kein Umschalt-Button mehr, die Preiszeile ist jetzt immer
1530         sichtbar, sobald das Sheet offen ist. -->
1531    <div id="ps-price-input-row" class="open">
1532      <div class="ps-stepper">
1533        <button type="button" class="ps-step-btn" id="ps-step-minus" aria-label="Preis verringern">−</button>
1534        <input id="ps-price-input" type="text" inputmode="decimal" placeholder="z.B. 1.059">
1535        <button type="button" class="ps-step-btn" id="ps-step-plus" aria-label="Preis erhöhen">+</button>
1536      </div>
1537      <button id="ps-price-submit">Melden</button>
1538    </div>
1539    <button class="price-sheet-btn danger" id="ps-btn-msg">✉ Nachricht an Admin</button>
1540    <div id="ps-msg-row">
1541      <textarea id="ps-msg-input" rows="3" placeholder="Ihre Nachricht an den Admin…" maxlength="500"></textarea>
1542      <button id="ps-msg-submit">Senden</button>
1543    </div>
1544    <button class="price-sheet-btn cancel" id="ps-btn-cancel">Abbrechen</button>
1545  </div>
1546</div>
1547
1548<!-- 05.09.2026 (User-Idee: "nach dem Erfolg und einem 'Danke!' wieder das grüne mit dem Button
1549     (ohne Hilfe) zeigen, dann kann daraus ein neues Update gestartet werden") - Wiederholungs-
1550     Overlay fuer den /mobile/q-Schnellsprung: nach erfolgreichem Preis-Update/-Bestaetigen im
1551     Quick-Jump-Kontext (?q) zeigt priceReport() statt der normalen Detail-Ansicht dieses Overlay
1552     (optisch identisch zu q.php: gruener Hintergrund, Blitz-Icon, "Preis melden"-Button, bewusst
1553     OHNE Anleitung) - ein Tap startet quickJumpToNearest() erneut fuer die naechste Tankstelle,
1554     ohne Umweg ueber Start/Suche/Liste. Ersetzt den (technisch auf iOS ohnehin unmoeglichen)
1555     Wunsch, das "Fenster" automatisch zu schliessen. -->
1556<div id="quickjump-again-overlay" hidden>
1557  <button type="button" id="qj-again-btn">
1558    <svg viewBox="0 0 24 24" fill="#ffd60a"><path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z"/></svg>
1559    <span>Preis melden</span>
1560  </button>
1561</div>
1562
1563<div class="wp-backdrop" id="wp-backdrop"></div>
1564<div class="wp-sheet" id="wp-sheet">
1565  <div class="wp-sheet-title">
1566    <span>Zwischenstopp</span>
1567    <button id="wp-sheet-close" aria-label="Schließen">✕</button>
1568  </div>
1569  <div class="route-field" style="margin-bottom:0">
1570    <input id="wp-input" type="text" placeholder="Adresse eingeben…" autocomplete="off">
1571  </div>
1572  <div id="wp-sugg" class="route-suggestions"></div>
1573  <button id="wp-add-btn" disabled>Hinzufügen</button>
1574</div>
1575
1576<div id="route-print-sheet" class="rpp-sheet"></div>
1577<div id="list-print-sheet" class="rpp-sheet"></div>
1578
1579<script>
1580// 11.08.2026 User-Report: Beim Kaltstart vom Homescreen bleibt unten kurzzeitig ein Rand frei
1581// ("genau da, wo bei Safari die Steuerleiste liegt") - laut User-Beobachtung ein bekanntes
1582// WebKit-Problem: die Standalone-PWA rendert den allerersten Frame noch mit dem Platzverbrauch
1583// einer normalen Safari-Tableiste, bevor iOS auf den echten Vollbild-Viewport umschaltet
1584// (behoben u.a. durch echte Touch-Interaktion - #app per position:fixed+inset:0 reicht allein
1585// nicht, weil der von iOS gemeldete sichtbare Bereich in diesem Moment selbst noch falsch ist,
1586// nicht nur die CSS-Berechnung). Alter iOS-Trick zum Erzwingen einer Chrome-Neuberechnung ohne
1587// echte Beruehrung: programmatischer 1px-Scroll kurz nach dem Start. Kein garantierter Fix (echte
1588// Touch-Events werden von iOS teils anders behandelt als synthetische), aber schadet nicht.
1589window.addEventListener('load', () => {
1590  setTimeout(() => { window.scrollTo(0, 1); window.scrollTo(0, 0); }, 50);
1591});
1592
1593// ── Mapkit init ──
1594const MAPKIT_TOKEN = 'eyJhbGciOiJFUzI1NiIsImtpZCI6Ik04MjlUOVE2WjgiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJHWTlRN0VXUzZNIiwiaWF0IjoxNzc0Nzc2Mzc3LCJleHAiOjE4MDYzMTIzNzcsIm9yaWdpbiI6IiouZ2FzLXRhbmtzdGVsbGVuLmRlIn0.mF6VqtlRtDMlRJVViOt9v36QJwOUe3R182JyOpllFGmHvbvjlP2rTsc6m2sxIZLt3o7P5P9LtDEvDsrX8D6yIg';
1595mapkit.init({ authorizationCallback: done => done(MAPKIT_TOKEN) });
1596
1597// ── Apple Sign In init ──
1598const APPLE_SERVICE_ID  = 'de.gas-tankstellen.loginwithapple';
1599const APPLE_REDIRECT_URI = 'https://www.gas-tankstellen.de/mobile/api/oauth_apple_callback.php';
1600const GOOGLE_CLIENT_ID  = '528440017242-6ldh4cdpujtbbt38j1qdis00nr4eh5ob.apps.googleusercontent.com';
1601if (APPLE_SERVICE_ID) {
1602  AppleID.auth.init({
1603    clientId:    APPLE_SERVICE_ID,
1604    scope:       'name email',
1605    redirectURI: APPLE_REDIRECT_URI,
1606    state:       encodeURIComponent(window.location.origin + '/mobile/'),
1607    usePopup:    true,
1608  });
1609}
1610
1611// 14.08.2026: detailMap/overviewMap/routeMap muessen VOR applyEffectiveTheme() deklariert sein,
1612// da die Funktion weiter unten (Zeile ~1326) sofort beim Laden aufgerufen wird - mit `let` weiter
1613// unten im Script fuehrte das zu einem ReferenceError (Temporal Dead Zone), der das komplette
1614// Skript abbrach und die App mit uebereinandergelegten Panels zurueckliess (Live-Vorfall, User:
1615// "ist auf dev total kaputt jetzt"). Die eigentlichen `let ...=null;`-Zeilen weiter unten im
1616// jeweiligen State-Block bleiben als reine Zuweisungen bestehen (ohne "let").
1617let detailMap = null, overviewMap = null, routeMap = null;
1618
1619// ── Theme (Hell/Dunkel/Auto, siehe Profil-View) ──
1620// Fruehe Anwendung passiert bereits im <head> (siehe oben) - hier folgen nur noch die
1621// Umschalter-Buttons und der Auto-Listener fuer Systemwechsel waehrend die App offen ist.
1622function applyEffectiveTheme() {
1623  const t = localStorage.getItem('gasdb_theme') || 'auto';
1624  const eff = t === 'auto' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : t;
1625  document.documentElement.setAttribute('data-theme', eff);
1626  document.querySelectorAll('[data-theme-choice]').forEach(b => b.classList.toggle('active', b.dataset.themeChoice === t));
1627  // 14.08.2026: bereits offene AppleMaps live nachziehen, falls der Umschalter waehrend eine
1628  // Karte sichtbar ist betaetigt wird (colorScheme ist bei mapkit.Map auch nachtraeglich
1629  // setzbar, kein Neuaufbau der Karte noetig).
1630  const scheme = pwaMkColorScheme();
1631  [detailMap, overviewMap, routeMap].forEach(m => { if (m) m.colorScheme = scheme; });
1632}
1633// 14.08.2026 User-Wunsch ("AppleMap im Dark-Mode auch dark verwenden"): die PWA hat ein eigenes,
1634// von der klassischen Website unabhaengiges Theme-System (data-theme-Attribut, siehe oben) - alle
1635// mapkit.Map()-Aufrufe in der PWA lasen dieses bisher gar nicht aus, MapKit blieb also immer hell.
1636// Einfach vom bereits gesetzten data-theme-Attribut ablesen statt die Theme-Logik zu duplizieren.
1637function pwaMkColorScheme() {
1638  return document.documentElement.getAttribute('data-theme') === 'dark'
1639    ? mapkit.Map.ColorSchemes.Dark
1640    : mapkit.Map.ColorSchemes.Light;
1641}
1642document.querySelectorAll('[data-theme-choice]').forEach(btn => {
1643  btn.addEventListener('click', () => {
1644    localStorage.setItem('gasdb_theme', btn.dataset.themeChoice);
1645    applyEffectiveTheme();
1646  });
1647});
1648window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
1649  if ((localStorage.getItem('gasdb_theme') || 'auto') === 'auto') applyEffectiveTheme();
1650});
1651applyEffectiveTheme();
1652
1653// 05.09.2026 (User-Idee, nachdem mehrere Anlaeufe scheiterten, aus der bereits als Standalone
1654// laufenden Haupt-App heraus per Link/target="_blank" zuverlaessig echtes Safari zu erreichen -
1655// iOS' Verhalten dafuer ist offenbar grundsaetzlich flakey: "kannst Du per Klick den Link in die
1656// Zwischenablage kopieren und einen Hinweis geben, den in Safari einzufuegen?") - kopiert die
1657// /mobile/q-URL statt zu navigieren, kein Verlassen des Standalone-Containers mehr noetig.
1658document.getElementById('btn-qgo-copy')?.addEventListener('click', async () => {
1659  const url = document.getElementById('btn-qgo-copy').dataset.qgoUrl;
1660  try {
1661    await navigator.clipboard.writeText(url);
1662    showToast('Link kopiert! Öffne Safari und füge ihn oben in die Adressleiste ein.', 4000);
1663  } catch (e) {
1664    showToast('Kopieren fehlgeschlagen. Adresse: ' + url, 5000);
1665  }
1666});
1667
1668// ── State ──
1669let currentMode    = localStorage.getItem('gasdb_mode') || 'lpg';
1670let currentStations  = [];
1671let currentAvgPrice  = null; // Durchschnittspreis der aktuellen Trefferliste
1672detailMap        = null;
1673overviewMap      = null;
1674let userLat        = null;
1675let userLng        = null;
1676// 18.07.26: letzter Umkreissuche-Mittelpunkt, damit die Radius-Stufen-Buttons
1677// auf der Karte die Suche mit neuem Radius wiederholen koennen, unabhaengig
1678// davon ob der Standort ueber GPS, Long-Press oder Adresssuche kam.
1679// 18.09.2026 Merkmals-Filter der normalen Suche (siehe #search-filter-panel), per localStorage gemerkt
1680function searchFilters() {
1681  return [...document.querySelectorAll('.sf-cb:checked')].map(cb => cb.value);
1682}
1683function searchFilterParam() {
1684  const f = searchFilters();
1685  return f.length ? '&filters=' + f.join(',') : '';
1686}
1687function sfUpdateUi() {
1688  const n = searchFilters().length;
1689  document.getElementById('sf-count').textContent = n ? '(' + n + ')' : '';
1690  document.getElementById('btn-search-filter').classList.toggle('active', n > 0);
1691  try { localStorage.setItem('searchFilters', JSON.stringify(searchFilters())); } catch (e) {}
1692}
1693document.getElementById('btn-search-filter').addEventListener('click', () => {
1694  const p = document.getElementById('search-filter-panel');
1695  p.style.display = p.style.display === 'grid' ? 'none' : 'grid';
1696});
1697try {
1698  const sfSaved = JSON.parse(localStorage.getItem('searchFilters') || '[]');
1699  document.querySelectorAll('.sf-cb').forEach(cb => { cb.checked = sfSaved.includes(cb.value); });
1700} catch (e) {}
1701document.querySelectorAll('.sf-cb').forEach(cb => cb.addEventListener('change', () => {
1702  sfUpdateUi();
1703  // bei sichtbarer Ergebnisliste sofort mit neuem Filter neu abfragen
1704  if ((activeView === 'list' || activeView === 'map') && lastSearchLat !== null && lastSearchLng !== null) {
1705    fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel);
1706  }
1707}));
1708sfUpdateUi();
1709
1710let lastSearchLat   = null;
1711let lastSearchLng   = null;
1712let lastSearchLabel = null;
1713const RADIUS_STEPS  = [2.5, 5, 7.5, 10, 20, 30];
1714
1715// ── Elemente ──
1716const loader       = document.getElementById('loader');
1717const toast        = document.getElementById('toast');
1718const searchInput  = document.getElementById('search-input');
1719const views        = { home: 'view-home', list: 'view-list', detail: 'view-detail', map: 'view-map', route: 'view-route', scanner: 'view-scanner', profile: 'view-profile', tankbuch: 'view-tankbuch', help: 'view-help' };
1720let currentUser    = null;  // null = nicht eingeloggt
1721let activeFavTab   = 'local';
1722let profileFavCache = null; // gecachte Profil-Favoriten
1723let activeView     = 'home';
1724
1725// ── Hilfsfunktionen ──
1726function showLoader(v) { loader.classList.toggle('show', v); }
1727
1728// 26.09.2026 (User-Wunsch: "und doppelt so lange sichtbar") - Standarddauer 2500ms -> 5000ms.
1729// Aufrufer mit eigener, bewusst kuerzerer/laengerer Dauer (z.B. das kurze "Danke!" im
1730// Quick-Jump-Ablauf) sind davon nicht betroffen, die geben ihr eigenes ms explizit mit.
1731// type: 'success' (gruen) | 'error' (rot) | weggelassen (neutral dunkel, wie bisher).
1732function showToast(msg, ms = 5000, type) {
1733  toast.textContent = msg;
1734  toast.classList.toggle('toast--success', type === 'success');
1735  toast.classList.toggle('toast--error', type === 'error');
1736  toast.style.display = 'block';
1737  clearTimeout(toast._t);
1738  toast._t = setTimeout(() => { toast.style.display = 'none'; }, ms);
1739}
1740
1741// 11.08.2026 User-Wunsch: eigener Toast am oberen Kartenrand (statt des globalen #toast über
1742// dem Header) für Karten-bezogene Hinweise wie "Auto-Folgen an/aus".
1743function showMapToast(msg, ms = 2000) {
1744  const el = document.getElementById('map-toast');
1745  if (!el) return;
1746  el.textContent = msg;
1747  el.style.display = 'block';
1748  clearTimeout(el._t);
1749  el._t = setTimeout(() => { el.style.display = 'none'; }, ms);
1750}
1751
1752function showView(name) {
1753  activeView = name;
1754  Object.values(views).forEach(id => {
1755    const el = document.getElementById(id);
1756    el.style.display = 'none';
1757  });
1758  const el = document.getElementById(views[name]);
1759  el.style.display = name === 'list' ? 'block' : 'flex';
1760  document.querySelectorAll('.nav-btn').forEach(b => {
1761    b.classList.toggle('active', b.dataset.view === name || (name === 'detail' && b.dataset.view === 'list'));
1762  });
1763  // Such- und Modusleiste bei Route-/Scanner-View ausblenden (Scanner braucht keine Textsuche,
1764  // die Kraftstoffart kommt weiterhin ueber die Modusleiste wie ueberall sonst)
1765  const searchBar = document.getElementById('search-bar');
1766  const modeBar   = document.getElementById('mode-bar');
1767  if (searchBar) searchBar.style.display = (name === 'route' || name === 'scanner') ? 'none' : '';
1768  // Filter-Panel gehoert zur Suchleiste: in Route/Scanner mit ausblenden (sonst hinge es allein da)
1769  if (name === 'route' || name === 'scanner') document.getElementById('search-filter-panel').style.display = 'none';
1770  if (modeBar)   modeBar.style.display   = (name === 'route' || currentUser?.gas || localStorage.getItem('gasdb_gas_locked')) ? 'none' : '';
1771  if (name === 'map') {
1772    initOverviewMap();
1773    // Radius-Label auch synchronisieren, wenn die Karte ueber eine Text-Suche
1774    // (statt fetchNearby) erreicht wurde und dort noch nie gesetzt wurde.
1775    const radiusSel = document.getElementById('gps-radius');
1776    if (radiusSel) updateMapRadiusUI(parseFloat(radiusSel.value));
1777    // 11.08.2026: Auto-Modus-Timer nur waehrend die Karte sichtbar ist (Akku/GPS sparen).
1778    startMapAutoTracking();
1779  } else {
1780    stopMapAutoTracking();
1781  }
1782  if (name === 'route') loadRouteHistory();
1783  // 27.07.26: kontinuierliches GPS-Tracking (watchPosition) nur waehrend die Scanner-View aktiv
1784  // ist starten/stoppen - spart Akku/Datenverbrauch in allen anderen Views.
1785  if (name === 'scanner') startScannerTracking(); else stopScannerTracking();
1786  // 27.07.26 User-Wunsch: Header/Modusleiste/Bottom-Nav sollen sich dem immer-dunklen Scanner
1787  // anpassen, unabhaengig von der eigentlichen Hell/Dunkel/Auto-Wahl im Profil - beim Verlassen
1788  // wird die tatsaechliche Wahl wieder hergestellt (applyEffectiveTheme() ganz oben im Script).
1789  if (name === 'scanner') document.documentElement.setAttribute('data-theme', 'dark');
1790  else applyEffectiveTheme();
1791}
1792
1793// ── Pull-to-Refresh (03.08.26, User-Wunsch) ─────────────────────────────────────────────────
1794// Nur fuer Views mit "aktualisierbaren" Daten (Liste/Karte/Profil-Favoriten) - bewusst NICHT fuer
1795// 'route' (eigene Drag-Interaktionen bei Wegpunkten) und 'scanner' (Kamera-/Kartengesten), um
1796// Gesten-Konflikte zu vermeiden. Fuer 'home'/'help'/'detail' gibt es nichts sinnvoll zu
1797// aktualisieren, daher dort ebenfalls kein Pull-Handler.
1798const PTR_THRESHOLD = 70; // px Ziehweite bis zum Ausloesen
1799const PTR_MAX        = 110; // px maximale sichtbare Ziehweite (Indikator-Anschlag)
1800
1801function ptrScrollEl() {
1802  // 'map' bewusst ausgeschlossen (03.08.26, Live-Bug): MapKit JS hat eine eigene Touch-Pan-Geste,
1803  // die sich mit dem generischen Pull-Handler biss (Kartenscrollen loeste faelschlic
1803h PTR aus).
1804  if (activeView === 'list' || activeView === 'profile') {
1805    return document.getElementById(views[activeView]);
1806  }
1807  return null;
1808}
1809
1810async function ptrRefreshCurrentView() {
1811  if (activeView === 'list') {
1812    if (lastSearchLat !== null && lastSearchLng !== null) {
1813      await fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel);
1814    }
1815  } else if (activeView === 'profile') {
1816    profileFavCache = null;
1817    renderFavoritesHome();
1818  }
1819}
1820
1821(function initPullToRefresh() {
1822  const indicator = document.getElementById('ptr-indicator');
1823  let startY = null, pulling = false, triggered = false;
1824
1825  document.addEventListener('touchstart', e => {
1826    const scrollEl = ptrScrollEl();
1827    if (!scrollEl || scrollEl.scrollTop > 0 || e.touches.length !== 1) { startY = null; return; }
1828    if (!scrollEl.contains(e.target)) { startY = null; return; }
1829    startY = e.touches[0].clientY;
1830    pulling = false; triggered = false;
1831  }, { passive: true });
1832
1833  document.addEventListener('touchmove', e => {
1834    if (startY === null) return;
1835    const dy = e.touches[0].clientY - startY;
1836    if (dy <= 0) { return; }
1837    const scrollEl = ptrScrollEl();
1838    if (!scrollEl || scrollEl.scrollTop > 0) { startY = null; indicator.classList.remove('ptr-visible'); return; }
1839    pulling = true;
1840    const dist = Math.min(dy * 0.5, PTR_MAX);
1841    indicator.style.transform = `translateY(${-40 + dist}px)`;
1842    indicator.classList.add('ptr-visible');
1843    indicator.classList.toggle('ptr-spin', dist >= PTR_THRESHOLD);
1844    triggered = dist >= PTR_THRESHOLD;
1845  }, { passive: true });
1846
1847  document.addEventListener('touchend', async () => {
1848    if (!pulling) { startY = null; return; }
1849    pulling = false;
1850    if (triggered) {
1851      indicator.style.transform = `translateY(${-40 + PTR_THRESHOLD}px)`;
1852      indicator.classList.add('ptr-visible', 'ptr-spin');
1853      try { await ptrRefreshCurrentView(); } catch(e) { /* Fehlermeldung uebernimmt fetchNearby selbst */ }
1854    }
1855    indicator.classList.remove('ptr-visible', 'ptr-spin');
1856    indicator.style.transform = 'translateY(-40px)';
1857    startY = null; triggered = false;
1858  });
1859})();
1860
1861function priceUnit(s) {
1862  // 08.09.26: Preise ausländischer Tankstellen stehen in Landeswährung
1863  // (tankstelle.lpg/cng), nicht in EUR — s.currency kommt aus der API
1864  // (mobile/api/search.php|nearby.php|route.php, via laender.inc.php).
1865  return (s && s.currency && s.currency !== 'EUR') ? s.currency : '€';
1866}
1867
1868function trendArrow(diff) {
1869  const d = parseFloat(diff);
1870  if (!d || isNaN(d)) return '';
1871  return d > 0
1872    ? ` <span class="trend-up">▲</span>`
1873    : ` <span class="trend-down">▼</span>`;
1874}
1875
1876function priceAge(ts) {
1877  if (!ts) return '';
1878  const days = Math.floor((Date.now() / 1000 - ts) / 86400);
1879  if (days === 0) return 'heute';
1880  if (days === 1) return 'gestern';
1881  if (days < 7)  return `vor ${days} Tagen`;
1882  if (days < 30) return `vor ${Math.floor(days/7)} Wo.`;
1883  return `vor ${Math.floor(days/30)} Mon.`;
1884}
1885
1886function gasIcon(gas) {
1887  if (gas === 'lpg')   return { cls: 'icon-lpg',  ch: '⛽' };
1888  if (gas === 'cng')   return { cls: 'icon-cng',  ch: '🔵' };
1889  return { cls: 'icon-both', ch: '⛽' };
1890}
1891
1892// ── Render Liste ──
1893function renderList(stations, title) {
1894  currentStations = stations;
1895  // Durchschnittspreis einmalig berechnen (geteilt mit Karten-Pins)
1896  const prices = stations.map(stationPrice).filter(Boolean);
1897  currentAvgPrice = prices.length ? prices.reduce((a, b) => a + b, 0) / prices.length : null;
1898
1899  const items = document.getElementById('list-items');
1900  const header = document.getElementById('list-title');
1901  const count  = document.getElementById('list-count');
1902  header.textContent = title || 'Ergebnisse';
1903  count.textContent  = `${stations.length} Tankstellen`;
1904  items.innerHTML = '';
1905
1906  document.getElementById('btn-list-print').classList.toggle('show', stations.length > 0);
1907  document.getElementById('list-print-sheet').innerHTML = !stations.length ? '' : `
1908    <h1>${esc(title || 'Ergebnisse')}</h1>
1909    <div class="rpp-meta">${stations.length} Tankstelle${stations.length !== 1 ? 'n' : ''}
1910      · ${currentMode === 'cng' ? 'CNG' : 'LPG'} · gedruckt am ${new Date().toLocaleDateString('de-DE')}</div>
1911    <table>
1912      <thead><tr><th></th><th>Tankstelle</th><th>Adresse</th><th>Entf.</th><th>Preis</th><th>Info</th></tr></thead>
1913      <tbody>
1914        ${stations.map((s, i) => {
1915          const price = stationPrice(s);
1916          const addr  = [s.strasse, [s.plz, s.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
1917          const dist  = s.abfahrt !== undefined
1918            ? (s.bab_direkt ? 'direkt' : `Ausf. ${s.abfahrt != null ? s.abfahrt : '–'}`)
1919            : (s.dist != null ? `${s.dist} km` : '–');
1920          const info  = s.h24 ? '24h' : '';
1921          return `<tr>
1922            <td><span class="rpp-num">${i+1}</span></td>
1923            <td>${esc(s.name)}</td>
1924            <td>${esc(addr)}</td>
1925            <td>${esc(dist)}</td>
1926            <td>${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : '–'}</td>
1927            <td>${esc(info)}</td>
1928          </tr>`;
1929        }).join('')}
1930      </tbody>
1931    </table>`;
1932
1933  if (!stations.length) {
1934    items.innerHTML = '<div style="padding:32px;text-align:center;color:#6b7280">Keine Tankstellen gefunden.<br>Suchradius oder Filter anpassen.</div>';
1935    return;
1936  }
1937
1938  stations.forEach((s, i) => {
1939    const ic = gasIcon(s.gas);
1940    const card = document.createElement('div');
1941    card.className = 'station-card' + (s.bab_direkt ? ' station-card-bab' : '');
1942
1943    const lpgTag = currentMode === 'lpg'
1944      ? (s.lpg
1945          ? `<span class="price-tag price-lpg">LPG ${s.lpg.toFixed(3)} ${priceUnit(s)}${trendArrow(s.lpg_diff)}${s.lpg_stand ? ` · ${priceAge(s.lpg_stand)}` : ''}</span>`
1946          : (s.gas === 'lpg' || s.gas === 'beides' ? '<span class="price-tag price-none">LPG k.A.</span>' : ''))
1947      : '';
1948    const cngTag = currentMode === 'cng'
1949      ? (s.cng
1950          ? `<span class="price-tag price-cng">CNG ${s.cng.toFixed(3)} ${priceUnit(s)}${trendArrow(s.cng_diff)}${s.cng_stand ? ` · ${priceAge(s.cng_stand)}` : ''}</span>`
1951          : (s.gas === 'cng' || s.gas === 'beides' ? '<span class="price-tag price-none">CNG k.A.</span>' : ''))
1952      : '';
1953
1954    // Autobahn-Suche liefert abfahrt/abfahrt_text/entfernung statt GPS-Distanz
1955    const isAutobahn = s.abfahrt !== undefined;
1956    let metaHtml;
1957    const h24Badge = s.h24 ? `<div class="h24-badge">⏰ 24h</div>` : '';
1958
1959    if (isAutobahn) {
1960      // Bei Autobahntankstellen (Entfernung=0, direkt an der Strecke) ist die
1961      // Ausfahrt-Nummer irrelevant -> nicht anzeigen, nur "direkt".
1962      metaHtml = s.bab_direkt
1963        ? `<div class="station-meta">
1964             <div class="dist-badge">🛣️</div>
1965             <div class="dist-unit">direkt</div>
1966             ${h24Badge}
1967           </div>`
1968        : `<div class="station-meta">
1969             <div class="dist-badge">Ausf. ${s.abfahrt != null ? s.abfahrt : '–'}</div>
1970             <div class="dist-unit">${s.entfernung} km</div>
1971             ${h24Badge}
1972           </div>`;
1973      var babInfo = `<div class="bab-ausfahrt">${s.bab_direkt ? '<span class="bab-badge">🛣️ Autobahntankstelle</span>' : esc(s.abfahrt_text || '')}</div>`;
1974    } else {
1975      const distText = s.dist != null ? `${s.dist}` : '–';
1976      metaHtml = `
1977      <div class="station-meta">
1978        <div class="dist-badge">${distText}</div>
1979        <div class="dist-unit">km</div>
1980        ${h24Badge}
1981      </div>`;
1982      var babInfo = '';
1983    }
1984
1985    card.innerHTML = `
1986      <div class="station-icon" style="background:${pinColor(stationPrice(s), currentAvgPrice)}">⛽</div>
1987      <div class="station-body">
1988        <div class="station-name">${esc(s.name)}</div>
1989        <div class="station-addr">${s.strasse ? esc(s.strasse) + ' · ' : ''}${esc(s.plz)} ${esc(s.ort)}</div>
1990        ${babInfo}
1991        <div class="station-prices">${lpgTag}${cngTag}</div>
1992        ${featBadges(s) ? `<div class="feat-badges" style="margin-top:5px">${featBadges(s)}</div>` : ''}
1993      </div>
1994      ${metaHtml}`;
1995    card.addEventListener('click', () => showDetail(s));
1996    items.appendChild(card);
1997  });
1998
1999  showView('list');
2000}
2001
2002document.getElementById('btn-list-print').addEventListener('click', () => printSheet('list-print-sheet'));
2003
2004// 10.08.26 Bugfix (User-Report: "AM iPhone hat Drucken keinerlei Funktion"): window.print() ist
2005// im iOS-Standalone-Modus (App vom Home-Bildschirm gestartet) ein stiller No-Op - Apple bindet
2006// den Druckdialog an die Safari-UI, die im Standalone-Container nicht existiert. In normalem
2007// Safari (Browser-Tab) oder als installierte PWA auf Android/Desktop funktioniert window.print()
2008// dagegen ganz normal. Fix nur fuer den iOS-Standalone-Fall: den fertig gebauten Druck-Sheet-Inhalt
2009// (siehe .rpp-sheet/@media print oben) ueber localStorage an eine eigene, schlanke Seite
2010// (print.php) uebergeben und die per window.open(...,'_blank') in echtem Safari oeffnen - das
2011// verlaesst zuverlaessig den Standalone-Container (anders als eine normale Same-Origin-Navigation,
2012// die im Container bleibt), und dort funktioniert window.print() wieder normal. print.php ruft
2013// window.print() selbst automatisch beim Laden auf.
2014function printSheet(sheetElId) {
2015  const isIosStandalone = window.navigator.standalone === true;
2016  if (!isIosStandalone) { window.print(); return; }
2017  const html = document.getElementById(sheetElId).innerHTML;
2018  try {
2019    localStorage.setItem('gasdb_print_payload', html);
2020  } catch (e) {
2021    alert('Drucken nicht möglich (Speicher voll). Bitte die Seite in Safari öffnen und dort erneut versuchen.');
2022    return;
2023  }
2024  window.open('print.php', '_blank');
2025}
2026
2027function esc(s) {
2028  return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2029}
2030
2031// ── Detail ──
2032function showDetail(s) {
2033  document.getElementById('detail-name').textContent = s.name;
2034  document.getElementById('detail-addr').textContent = `${s.strasse}, ${s.plz} ${s.ort}`;
2035
2036  // Preise — nur passend zum aktuellen Modus anzeigen
2037  const hasCurrentPrice = (currentMode === 'lpg' && s.lpg) || (currentMode === 'cng' && s.cng);
2038  let pricesHtml = '';
2039  if (currentMode === 'lpg' && (s.gas === 'lpg' || s.gas === 'beides')) {
2040    pricesHtml += `<div class="detail-price-box lpg">
2041      <div class="gas-label">LPG AutoGas</div>
2042      <div class="gas-price">${s.lpg ? s.lpg.toFixed(3) + ' ' + priceUnit(s) : 'k.A.'}</div>
2043      <div class="gas-age">${s.lpg ? priceAge(s.lpg_stand) : ''}</div>
2044    </div>`;
2045  }
2046  if (currentMode === 'cng' && (s.gas === 'cng' || s.gas === 'beides')) {
2047    pricesHtml += `<div class="detail-price-box cng">
2048      <div class="gas-label">CNG Erdgas</div>
2049      <div class="gas-price">${s.cng ? s.cng.toFixed(3) + ' ' + priceUnit(s) : 'k.A.'}</div>
2050      <div class="gas-age">${s.cng ? priceAge(s.cng_stand) : ''}</div>
2051    </div>`;
2052  }
2053  // 04.09.2026 (User-Idee: "rechts daneben ein Quadrat gleicher Höhe mit Check-Haken als
2054  // schnelle Preis-Bestätigung") - eigenes Quadrat neben der Preis-Box, bestätigt den Preis mit
2055  // einem Tap statt erst das ganze Preismeldungs-Sheet zu öffnen. Nur wenn eingeloggt und ein
2056  // Preis vorhanden ist (nichts zu bestätigen, wenn "k.A.").
2057  if (currentUser && hasCurrentPrice) {
2058    pricesHtml += `<button type="button" class="detail-price-confirm-btn" id="btn-quick-confirm" title="Preis bestätigen" aria-label="Preis bestätigen">
2059      <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 12 9 17 20 6"/></svg>
2060    </button>`;
2061  }
2062  document.getElementById('detail-prices').innerHTML = pricesHtml;
2063
2064  // Preis-Box anklickbar wenn eingeloggt → direkt Preiseingabe öffnen
2065  if (currentUser) {
2066    document.querySelectorAll('.detail-price-box').forEach(box => {
2067      box.style.cursor = 'pointer';
2068      box.addEventListener('click', () => {
2069        openPriceSheet(s);
2070        document.getElementById('ps-msg-row').classList.remove('open');
2071        // 04.09.2026: kein automatischer .focus() mehr, siehe initPriceStepper()-Kommentar.
2072        // Preiszeile ist inzwischen immer offen, kein .add('open') mehr noetig.
2073      });
2074    });
2075    document.getElementById('btn-quick-confirm')?.addEventListener('click', () => quickConfirmPrice(s));
2076  }
2077
2078  // Infos
2079  // 05.09.2026 (User: "kannst du den Bereich 2-spaltig machen? 7tage & Öffnungszeiten / Telefon
2080  // & Entfernung / links & rechts-bündig") - alle Fakten einheitlich als row() in ein
2081  // 2-spaltiges CSS-Grid, das sie in Erzeugungsreihenfolge automatisch paart (siehe CSS:
2082  // #detail-infos), linke Spalte linksbündig mit Icon vorne, rechte Spalte rechtsbündig mit
2083  // Icon hinten (:nth-child(2n)).
2084  // 18.09.2026 (User: "der LKW ist bei #26543 verschwunden" - Icon-only-Zeilen im Grid waren zu
2085  // klein/unauffaellig; "reine Badges für EC/KK und LKW die wenig Platz brauchen") - giro/kk/lkw
2086  // laufen jetzt als kompakte Pill-Badges statt als row()-Zeilen, siehe featBadges().
2087  document.getElementById('detail-badges').innerHTML = featBadges(s);
2088
2089  let infos = '';
2090  if (s.h24)  infos += row('🕐', '24h');
2091  if (s.d7)   infos += row('📅', '7 Tage/Woche');
2092  if (s.oez && !s.h24) infos += row('🕐', esc(s.oez));
2093  if (s.tel)  {
2094    // tel: nur mit erlaubten Zeichen — verhindert javascript:-Protokoll-Injection
2095    const telSafe = String(s.tel).replace(/[^0-9+\-() \/]/g, '');
2096    if (telSafe) infos += row('📞', `<a href="tel:${telSafe}" style="color:var(--blue)">${esc(s.tel)}</a>`);
2097  }
2098  if (s.dist) infos += row('📍', `${s.dist} km entfernt`);
2099  if (s.abfahrt !== undefined) {
2100    if (s.bab_direkt) {
2101      infos += row('🛣️', '<b>Autobahntankstelle</b> – direkt an der Strecke');
2102    } else {
2103      infos += row('🛣️', `Ausfahrt ${esc(String(s.abfahrt ?? ''))} ${esc(s.abfahrt_text || '')} – ${s.entfernung} km entfernt`);
2104    }
2105  }
2106  document.getElementById('detail-infos').innerHTML = infos;
2107
2108  // Buttons
2109  const isIOS    = /iPad|iPhone|iPod/.test(navigator.userAgent);
2110  const isAndroid = /Android/.test(navigator.userAgent);
2111  // 11.08.2026 Bugfix (User-Report: "Navigieren" auf Android ohne Funktion, iOS problemlos):
2112  // das geo:-URI-Schema wird aus einem Browser/einer PWA heraus (anders als aus einer echten
2113  // nativen App) auf Android oft nicht zuverlaessig an eine Karten-App weitergereicht - Google
2114  // empfiehlt fuer Web-Kontexte explizit die HTTPS-"Universal"-URL statt geo: (siehe
2115  // developers.google.com/maps/documentation/urls/get-started).
2116  const geoUrl   = isIOS
2117    ? `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`
2118    : isAndroid
2119      ? `https://www.google.com/maps/dir/?api=1&destination=${s.lat},${s.lng}`
2120      : `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`;
2121  const favActive = isFavorite(s.id) ? ' active' : '';
2122  const profActive = isProfileFavorite(s.id) ? ' active' : '';
2123  const profBtn = currentUser
2124    ? `<button class="action-btn fav-profile${profActive}" id="btn-profile-fav" title="Im Profil speichern">★</button>`
2125    : '';
2126  // 05.09.2026 (User: "in der Detail-Ansicht ist der graue Button zum Preisupdate nicht mehr
2127  // nötig") - reportBtn (±✓) war ein separater Weg zum Preismeldungs-Sheet, seit der Preiseingabe-
2128  // Überarbeitung vom Vortag aber redundant: die Preis-Box selbst öffnet das Sheet per Klick
2129  // (auch ohne vorhandenen Preis, siehe unten), das Quick-Bestätigen-Quadrat übernimmt die
2130  // schnelle Bestätigung. hasPrice bleibt fuer das Quick-Bestätigen-Quadrat weiterhin nötig.
2131  const hasPrice = (currentMode === 'lpg' && s.lpg) || (currentMode === 'cng' && s.cng);
2132  document.getElementById('detail-actions').innerHTML = `
2133    <a href="${geoUrl}" class="action-btn nav">
2134      <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
2135        <polygon points="3 11 22 2 13 21 11 13 3 11"/>
2136      </svg> Navigieren
2137    </a>
2138    <button class="action-btn fav${favActive}" id="btn-fav-toggle" title="Lokal speichern">♥</button>
2139    ${profBtn}`;
2140  document.getElementById('btn-fav-toggle').addEventListener('click', () => toggleFavorite(s));
2141  if (currentUser) {
2142    document.getElementById('btn-profile-fav').addEventListener('click', async () => {
2143      if (isProfileFavorite(s.id)) {
2144        await removeProfileFavorite(s.id, null);
2145        updateProfileFavBtn(s.id, false);
2146        showToast('Aus Profil entfernt');
2147      } else {
2148        await addProfileFavorite(s);
2149      }
2150    });
2151  }
2152
2153  showView('detail');
2154
2155  // Detail-Karte
2156  setTimeout(() => {
2157    if (detailMap) { detailMap.destroy(); detailMap = null; }
2158    const coord = new mapkit.Coordinate(s.lat, s.lng);
2159    detailMap = new mapkit.Map('detail-map', {
2160      center: coord,
2161      region: new mapkit.CoordinateRegion(coord, new mapkit.CoordinateSpan(0.005, 0.005)),
2162      showsCompass: mapkit.FeatureVisibility.Hidden,
2163      showsZoomControl: false, showsMapTypeControl: false,
2164      showsUserLocation: true,
2165      colorScheme: pwaMkColorScheme(),
2166    });
2167    detailMap.addAnnotation(new mapkit.MarkerAnnotation(coord, {
2168      title: s.name, color: '#1D4CA3', glyphText: '⛽'
2169    }));
2170  }, 50);
2171}
2172
2173function row(icon, text) {
2174  return `<div class="detail-info-row"><span class="detail-info-icon">${icon}</span><span>${text}</span></div>`;
2175}
2176
2177// 18.09.2026 (User: "reine Badges für EC/KK und LKW die wenig Platz brauchen... brauchen wir ja
2178// dann auch für die Liste") - kompakte Pill-Badges statt Zeilen im 2-spaltigen Infos-Grid,
2179// gemeinsam genutzt von Detail- und Listenansicht.
2180function featBadges(s) {
2181  let h = '';
2182  if (s.lkw)  h += `<span class="feat-badge feat-badge--lkw" title="LKW-geeignet">🚚 LKW</span>`;
2183  if (s.giro) h += `<span class="feat-badge feat-badge--giro" title="EC/GiroCard">💳 EC</span>`;
2184  if (s.kk)   h += `<span class="feat-badge feat-badge--kk" title="Kreditkarte (Mastercard/Visa)">💳 KK</span>`;
2185  return h;
2186}
2187
2188// ── Offline-Cache der letzten Ergebnisliste (User-Wunsch: "Daten der gefundenen TS im
2189// localStorage halten, falls keine Datenverbindung vorhanden ist") - wird nach jeder
2190// erfolgreichen Umkreis-/Text-Suche ueberschrieben, bei einem Netzwerkfehler in fetchNearby()/
2191// fetchSearch() als Fallback angezeigt statt nur einer Fehlermeldung. ──
2192const OFFLINE_CACHE_KEY = 'gasdb_last_results';
2193
2194function saveOfflineCache(stations, title) {
2195  if (!stations || !stations.length) return;
2196  try {
2197    localStorage.setItem(OFFLINE_CACHE_KEY, JSON.stringify({ stations, title, mode: currentMode, ts: Date.now() }));
2198  } catch (e) { /* z.B. Speicher voll - kein kritischer Fehler, Cache bleibt einfach alt */ }
2199}
2200
2201function loadOfflineCache() {
2202  try {
2203    const raw = localStorage.getItem(OFFLINE_CACHE_KEY);
2204    return raw ? JSON.parse(raw) : null;
2205  } catch (e) { return null; }
2206}
2207
2208// Zeigt die gecachten Ergebnisse an, falls vorhanden - true bei Erfolg, sonst false (dann bleibt
2209// die normale Fehlermeldung im Aufrufer bestehen).
2210function tryOfflineFallback() {
2211  const cache = loadOfflineCache();
2212  if (!cache || !cache.stations || !cache.stations.length) return false;
2213  const ageMin = Math.round((Date.now() - cache.ts) / 60000);
2214  const ageTxt = ageMin < 1 ? 'gerade eben' : ageMin < 60 ? `vor ${ageMin} Min.` : `vor ${Math.round(ageMin / 60)} Std.`;
2215  renderList(cache.stations, cache.title);
2216  showToast(`Keine Verbindung – zeige letzte Ergebnisse (${ageTxt})`);
2217  return true;
2218}
2219
2220// Dauerhafter, dezenter OFFLINE-Stempel im Header (User-Wunsch), nicht nur ein einmaliger Toast -
2221// reagiert live auf online/offline-Events UND prueft den Zustand einmal beim Laden.
2222function updateOfflineBanner() {
2223  const badge = document.getElementById('offline-badge');
2224  if (badge) badge.style.display = navigator.onLine === false ? 'inline-block' : 'none';
2225}
2226window.addEventListener('online', updateOfflineBanner);
2227window.addEventListener('offline', updateOfflineBanner);
2228updateOfflineBanner();
2229
2230// 11.09.2026 (User-Fund: aus dem Hintergrund reaktivierte PWA zeigt ohne vollstaendigen
2231// Neustart weiter den alten Stand, "das verwirrt den normalen User denke ich") - kein Service
2232// Worker vorhanden, daher hier ein leichtgewichtiger Eigenbau: Boot-Version (vom Server
2233// gerendert) wird bei jedem "Seite wieder sichtbar"-Ereignis gegen mobile/api/version.php
2234// verglichen; bei Abweichung erscheint ein Banner statt kommentarlos veraltet zu bleiben.
2235// Bewusst KEIN automatisches Reload (koennte mitten in einer Eingabe/Suche stoeren) - der
2236// User entscheidet per Klick, wann er aktualisiert.
2237const BOOT_VERSION = "26.10.01-4";
2238let _updateBannerShown = false;
2239
2240async function checkForUpdate() {
2241  if (_updateBannerShown) return;
2242  try {
2243    const res = await fetch('api/version.php', { cache: 'no-store' });
2244    const data = await res.json();
2245    if (data.version && data.version !== BOOT_VERSION) {
2246      _updateBannerShown = true;
2247      document.getElementById('update-banner').style.display = 'block';
2248    }
2249  } catch (e) { /* kein Netz o.ae. - kein kritischer Fehler, naechster Versuch reicht */ }
2250}
2251document.addEventListener('visibilitychange', () => {
2252  if (document.visibilityState === 'visible') checkForUpdate();
2253});
2254
2255// ── Favoriten (lokal, max. 10, localStorage) ──
2256const FAV_KEY = 'gasdb_favorites';
2257const FAV_MAX = 10;
2258
2259function getFavorites() {
2260  try {
2261    const raw = localStorage.getItem(FAV_KEY);
2262    return raw ? JSON.parse(raw) : [];
2263  } catch (e) { return []; }
2264}
2265
2266function saveFavorites(list) {
2267  localStorage.setItem(FAV_KEY, JSON.stringify(list));
2268}
2269
2270function isFavorite(id) {
2271  return getFavorites().some(f => f.id === id);
2272}
2273
2274function toggleFavorite(s) {
2275  let favs = getFavorites();
2276  const idx = favs.findIndex(f => f.id === s.id);
2277  if (idx >= 0) {
2278    favs.splice(idx, 1);
2279    showToast('Favorit entfernt');
2280  } else {
2281    if (favs.length >= FAV_MAX) {
2282      showToast(`Maximal ${FAV_MAX} Favoriten möglich`);
2283      return;
2284    }
2285    favs.push(s);
2286    showToast('Als Favorit gespeichert');
2287  }
2288  saveFavorites(favs);
2289  const btn = document.getElementById('btn-fav-toggle');
2290  if (btn) btn.classList.toggle('active', isFavorite(s.id));
2291  renderFavoritesHome();
2292}
2293
2294function removeFavorite(id, ev) {
2295  if (ev) ev.stopPropagation();
2296  saveFavorites(getFavorites().filter(f => f.id !== id));
2297  renderFavoritesHome();
2298}
2299
2300function favPriceTag(f) {
2301  const tag = (val, stand, diff, cls, label) => val
2302    ? `<span class="price-tag ${cls}">${label} ${val.toFixed(3)} ${priceUnit(f)}${trendArrow(diff)} · ${priceAge(stand)}</span>`
2303    : (f.gas === label.toLowerCase() || f.gas === 'beides' ? `<span class="price-tag price-none">${label} k.A.</span>` : '');
2304  if (currentMode === 'lpg') return tag(f.lpg, f.lpg_stand, f.lpg_diff, 'price-lpg', 'LPG');
2305  if (currentMode === 'cng') return tag(f.cng, f.cng_stand, f.cng_diff, 'price-cng', 'CNG');
2306  return tag(f.lpg, f.lpg_stand, f.lpg_diff, 'price-lpg', 'LPG') + tag(f.cng, f.cng_stand, f.cng_diff, 'price-cng', 'CNG');
2307}
2308
2309function renderFavRow(f) {
2310  return `
2311    <div class="fav-row" onclick='showDetail(${JSON.stringify(f).replace(/'/g, "&#39;")})'>
2312      <div class="fav-row-body">
2313        <div class="fav-row-name">${esc(f.name)}</div>
2314        <div class="fav-row-addr">${f.strasse ? esc(f.strasse) + ' · ' : ''}${esc(f.plz)} ${esc(f.ort)}</div>
2315        <div class="fav-row-prices">${favPriceTag(f)}</div>
2316      </div>
2317      <button class="fav-row-remove" onclick="removeFavorite(${f.id}, event)">✕</button>
2318    </div>`;
2319}
2320
2321function renderFavoritesHome() {
2322  if (activeFavTab === 'profile' && currentUser) { renderProfileFavorites(); return; }
2323  const favs = getFavorites();
2324  const el = document.getElementById('fav-section');
2325  if (!favs.length) { el.innerHTML = ''; return; }
2326  el.innerHTML = `<h3>♥ Lokale Favoriten (${favs.length}/${FAV_MAX})</h3>` + favs.map(renderFavRow).join('');
2327
2328  // Frische Preise nachladen (Snapshot beim Favorisieren kann veraltet sein).
2329  // Stationen, die der Server nicht mehr liefert (gesperrt, geloescht, oder
2330  // nachtraeglich "Versorger-non-grata" geworden -> vers_lpg < 0), werden
2331  // automatisch aus den Favoriten entfernt.
2332  const ids = favs.map(f => f.id).join(',');
2333  fetch(`api/favorites.php?ids=${ids}`)
2334    .then(r => r.json())
2335    .then(data => {
2336      if (!data.ok) return;
2337      const updated = getFavorites()
2338        .map(f => {
2339          const fresh = data.stations.find(x => x.id === f.id);
2340          return fresh ? { ...f, lpg: fresh.lpg, cng: fresh.cng, lpg_stand: fresh.lpg_stand, cng_stand: fresh.cng_stand, lpg_diff: fresh.lpg_diff, cng_diff: fresh.cng_diff } : null;
2341        })
2342        .filter(f => f !== null);
2343      saveFavorites(updated);
2344      el.innerHTML = updated.length
2345        ? `<h3>♥ Lokale Favoriten (${updated.length}/${FAV_MAX})</h3>` + updated.map(renderFavRow).join('')
2346        : '';
2347    })
2348    .catch(() => {}); // offline o.ä. — Snapshot bleibt unangetastet sichtbar
2349}
2350
2351// ── Profil-Favoriten (Server) ────────────────────────────────────────────────
2352
2353function renderProfileFavRow(f) {
2354  return `
2355    <div class="fav-row" onclick='showDetail(${JSON.stringify(f).replace(/'/g, "&#39;")})'>
2356      <div class="fav-row-body">
2357        <div class="fav-row-name">${esc(f.name)}</div>
2358        <div class="fav-row-addr">${f.strasse ? esc(f.strasse) + ' · ' : ''}${esc(f.plz)} ${esc(f.ort)}</div>
2359        <div class="fav-row-prices">${favPriceTag(f)}</div>
2360      </div>
2361      <button class="fav-row-remove" onclick="removeProfileFavorite(${f.id}, event)">✕</button>
2362    </div>`;
2363}
2364
2365async function renderProfileFavorites() {
2366  const el = document.getElementById('fav-section');
2367  if (!profileFavCache) {
2368    el.innerHTML = '<p style="padding:12px 4px;color:var(--sub);font-size:.85rem">Lade …</p>';
2369    try {
2370      const r = await fetch(`api/favorites.php?action=list&mode=${currentMode}`);
2371      const d = await r.json();
2372      profileFavCache = d.ok ? d.stations : [];
2373    } catch(e) { profileFavCache = []; }
2374  }
2375  if (!profileFavCache.length) {
2376    el.innerHTML = '<p style="padding:12px 4px;color:var(--sub);font-size:.85rem">Keine Profil-Favoriten gespeichert.</p>';
2377    return;
2378  }
2379  el.innerHTML = `<h3>★ Profil-Favoriten (${profileFavCache.length})</h3>` + profileFavCache.map(renderProfileFavRow).join('');
2380}
2381
2382async function addProfileFavorite(s) {
2383  if (!currentUser) return;
2384  const fd = new FormData();
2385  fd.append('action', 'add'); fd.append('tankid', s.id);
2386  const r = await fetch('api/favorites.php', { method: 'POST', body: fd });
2387  const d = await r.json();
2388  if (d.ok) {
2389    profileFavCache = null; // Cache invalidieren
2390    showToast('Im Profil gespeichert');
2391    updateProfileFavBtn(s.id, true);
2392  }
2393}
2394
2395async function removeProfileFavorite(tankid, ev) {
2396  if (ev) ev.stopPropagation();
2397  const fd = new FormData();
2398  fd.append('action', 'remove'); fd.append('tankid', tankid);
2399  await fetch('api/favorites.php', { method: 'POST', body: fd });
2400  profileFavCache = null;
2401  renderFavoritesHome();
2402}
2403
2404function isProfileFavorite(id) {
2405  return profileFavCache ? profileFavCache.some(f => f.id === id) : false;
2406}
2407
2408function updateProfileFavBtn(id, active) {
2409  const btn = document.getElementById('btn-profile-fav');
2410  if (btn) btn.classList.toggle('active', active);
2411}
2412
2413// Tab-Handler
2414document.querySelectorAll('.fav-tab').forEach(btn => {
2415  btn.addEventListener('click', () => {
2416    activeFavTab = btn.dataset.tab;
2417    document.querySelectorAll('.fav-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === activeFavTab));
2418    renderFavoritesHome();
2419  });
2420});
2421
2422// ── Preisfarbe für Karten-Pins ──
2423function stationPrice(s) {
2424  // Relevanten Preis je nach Modus wählen; bei 'all' LPG bevorzugen
2425  return currentMode === 'cng' ? s.cng : (s.lpg ?? s.cng);
2426}
2427
2428// 19.07.26 User-Wunsch: statt fester Farbstufen ein fliessender Verlauf - je guenstiger, desto
2429// heller/leuchtender das Gruen, je teurer, desto dunkler das Rot. Ausserhalb der Durchschnitts-
2430// Zone (±2ct, bleibt blau wie zuvor) wird die Abweichung vom Schnitt auf 0..1 normiert
2431// (voll gesaettigt ab ±8ct) und zwischen zwei Ankerfarben interpoliert.
2432function _lerpChannel(a, b, t) { return Math.round(a + (b - a) * t); }
2433function _lerpHexColor(hex1, hex2, t) {
2434  const toRgb = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
2435  const [r1, g1, b1] = toRgb(hex1);
2436  const [r2, g2, b2] = toRgb(hex2);
2437  return `rgb(${_lerpChannel(r1, r2, t)},${_lerpChannel(g1, g2, t)},${_lerpChannel(b1, b2, t)})`;
2438}
2439
2440function pinColor(price, avg) {
2441  if (!price || !avg) return '#e5e7eb'; // kein Preis → hellgrau
2442  const diff = price - avg;             // positiv = teurer als Durchschnitt
2443  const AVG_BAND = 0.02;                // ±2ct gilt weiterhin als "im Durchschnitt"
2444  const MAX_SPREAD = 0.08;              // ab ±8ct voll gesaettigte Farbe
2445  if (Math.abs(diff) <= AVG_BAND) return '#3b82f6'; // Durchschnitt: mittelblau, wie zuvor
2446
2447  const t = Math.min(1, (Math.abs(diff) - AVG_BAND) / (MAX_SPREAD - AVG_BAND));
2448  if (diff < 0) {
2449    // günstiger als Durchschnitt: Grün, je billiger desto heller
2450    return _lerpHexColor('#16a34a', '#86efac', t); // dunkleres → helles/leuchtendes Grün
2451  }
2452  // teurer als Durchschnitt: Rot, je teurer desto dunkler
2453  return _lerpHexColor('#fca5a5', '#7f1d1d', t); // helles → dunkles Rot
2454}
2455
2456// ── Scanner (27.07.26, Integration des bisher isolierten mobile/scanner.php-Prototyps -
2457// User-Idee: "Tankstellen-Radar", zeigt Stationen im Sektor voraus per GPS-Kurs/Kompass an,
2458// siehe Memory project_pwa_scanner. Nutzt bewusst dieselben pinColor()/stationPrice()/
2459// currentMode wie der Rest der App statt eigener Kopien (Unterschied zum eigenstaendigen
2460// Prototyp, der noch alles dupliziert hatte). ──
2461const SC_SECTOR_HALF = 60;          // Grad, Halb-Oeffnungswinkel des Sektors voraus
2462const SC_MOVING_MIN_SPEED = 1.0;    // m/s (~3.6 km/h) - darunter gilt GPS-Kurs als unzuverlaessig
2463let scRangeKm = 5;
2464let scUserLat = null, scUserLng = null, scHeading = null, scHeadingSource = null;
2465let scStations = [];
2466let scLastFetchLat = null, scLastFetchLng = null;
2467let scSelectedBlip = null;
2468let scSweepAngle = -SC_SECTOR_HALF, scSweepDir = 1;
2469let scWatchId = null;
2470let scDrawing = false;
2471let scListInterval = null, scStatusInterval = null;
2472
2473const scCanvas = document.getElementById('sc-radar');
2474const scCtx = scCanvas.getContext('2d');
2475const scStatusEl = document.getElementById('sc-status');
2476const scBtnCompass = document.getElementById('sc-btn-compass');
2477document.getElementById('sc-sector-deg').textContent = SC_SECTOR_HALF;
2478
2479function scToRad(d) { return d * Math.PI / 180; }
2480function scNormAngle(a) { a = a % 360; return a < 0 ? a + 360 : a; }
2481function scNormDiff(a) { return ((a + 180) % 360 + 360) % 360 - 180; }
2482
2483function scBearing(lat1, lng1, lat2, lng2) {
2484  const φ1 = scToRad(lat1), φ2 = scToRad(lat2), Δλ = scToRad(lng2 - lng1);
2485  const y = Math.sin(Δλ) * Math.cos(φ2);
2486  const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
2487  return scNormAngle(Math.atan2(y, x) * 180 / Math.PI);
2488}
2489function scDistanceKm(lat1, lng1, lat2, lng2) {
2490  const R = 6371, φ1 = scToRad(lat1), φ2 = scToRad(lat2), Δφ = scToRad(lat2 - lat1), Δλ = scToRad(lng2 - lng1);
2491  const a = Math.sin(Δφ/2)**2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2)**2;
2492  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
2493}
2494
2495function startScannerTracking() {
2496  if (scWatchId !== null) return; // laeuft schon
2497  if (!navigator.geolocation) { scStatusEl.textContent = 'GPS auf diesem Gerät nicht verfügbar.'; scStatusEl.className = 'warn'; return; }
2498  scWatchId = navigator.geolocation.watchPosition(pos => {
2499    scUserLat = pos.coords.latitude;
2500    scUserLng = pos.coords.longitude;
2501    const spd = pos.coords.speed;
2502    if (pos.coords.heading !== null && !isNaN(pos.coords.heading) && (spd === null || spd >= SC_MOVING_MIN_SPEED)) {
2503      scHeading = pos.coords.heading;
2504      scHeadingSource = 'gps';
2505    }
2506    scMaybeRefetch();
2507  }, err => {
2508    scStatusEl.textContent = 'Standort nicht verfügbar: ' + err.message;
2509    scStatusEl.className = 'warn';
2510  }, { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000 });
2511
2512  if (!scDrawing) { scDrawing = true; scFitCanvas(); requestAnimationFrame(scDrawRadar); }
2513  if (!scListInterval) scListInterval = setInterval(scRenderSectorList, 1000);
2514  if (!scStatusInterval) scStatusInterval = setInterval(scUpdateStatus, 1000);
2515  scUpdateCompassBtnVisibility();
2516  // 28.07.26 User-Wunsch: Querformat verhindern. Bonus fuer installierte Android-PWAs (echter
2517  // OS-Lock) - schlaegt ueberall sonst (u.a. immer auf iOS/Safari) einfach fehl, das ist der
2518  // Normalfall, kein Fehlerzustand. Die zuverlaessige Loesung ist der CSS-Dreh-Hinweis oben.
2519  try { screen.orientation?.lock?.('portrait').catch(() => {}); } catch (e) {}
2520}
2521function stopScannerTracking() {
2522  if (scWatchId !== null) { navigator.geolocation.clearWatch(scWatchId); scWatchId = null; }
2523  scDrawing = false;
2524  if (scListInterval) { clearInterval(scListInterval); scListInterval = null; }
2525  if (scStatusInterval) { clearInterval(scStatusInterval); scStatusInterval = null; }
2526  try { screen.orientation?.unlock?.(); } catch (e) {}
2527}
2528
2529function scEnableCompass() {
2530  const done = () => {
2531    window.addEventListener('deviceorientationabsolute', scOnOrientation, true);
2532    window.addEventListener('deviceorientation', scOnOrientation, true);
2533    scBtnCompass.style.display = 'none';
2534  };
2535  if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
2536    DeviceOrientationEvent.requestPermission().then(state => {
2537      if (state === 'granted') done();
2538      else showToast('Kompass-Zugriff abgelehnt.');
2539    }).catch(() => showToast('Kompass nicht verfügbar.'));
2540  } else {
2541    done();
2542  }
2543}
2544scBtnCompass.addEventListener('click', scEnableCompass);
2545function scOnOrientation(e) {
2546  let h = null;
2547  if (typeof e.webkitCompassHeading === 'number') h = e.webkitCompassHeading;
2548  else if (e.absolute && e.alpha !== null) h = scNormAngle(360 - e.alpha);
2549  if (h !== null) { scHeading = h; scHeadingSource = 'compass'; }
2550}
2551function scUpdateCompassBtnVisibility() {
2552  scBtnCompass.style.display = (scHeadingSource !== 'gps' && scHeadingSource !== 'compass') ? 'inline-block' : 'none';
2553}
2554
2555async function scMaybeRefetch() {
2556  if (scUserLat === null) return;
2557  const moved = scLastFetchLat === null || scDistanceKm(scLastFetchLat, scLastFetchLng, scUserLat, scUserLng) > 0.3;
2558  if (!moved) return;
2559  scLastFetchLat = scUserLat; scLastFetchLng = scUserLng;
2560  try {
2561    const r = await fetch(`api/nearby.php?lat=${scUserLat}&lng=${scUserLng}&mode=${currentMode}&r=${scRangeKm}`);
2562    const data = await r.json();
2563    if (data.ok) scStations = data.stations;
2564  } catch (e) { /* stiller Retry beim naechsten watchPosition-Tick */ }
2565}
2566
2567document.querySelectorAll('[data-sc-range]').forEach(b => b.addEventListener('click', () => {
2568  document.querySelectorAll('[data-sc-range]').forEach(x => x.classList.toggle('active', x === b));
2569  scRangeKm = parseFloat(b.dataset.scRange);
2570  scLastFetchLat = null; scMaybeRefetch();
2571}));
2572
2573function scStationsInSector() {
2574  if (scUserLat === null || scHeading === null) return [];
2575  return scStations
2576    .filter(s => s.lat && s.lng && stationPrice(s))
2577    .map(s => {
2578      const dist = scDistanceKm(scUserLat, scUserLng, s.lat, s.lng);
2579      const brg = scBearing(scUserLat, scUserLng, s.lat, s.lng);
2580      const rel = scNormDiff(brg - scHeading);
2581      return { s, dist, rel };
2582    })
2583    .filter(x => Math.abs(x.rel) <= SC_SECTOR_HALF && x.dist <= scRangeKm)
2584    .sort((a, b) => a.dist - b.dist);
2585}
2586
2587function scFitCanvas() {
2588  const wrap = document.getElementById('sc-radar-wrap');
2589  // 28.07.26 Bugfix: im Querformat blendet CSS #sc-radar-wrap per display:none aus (siehe
2590  // Dreh-Hinweis weiter oben) - clientWidth/-Height sind dann 0, die Berechnung unten wuerde
2591  // negative Werte liefern. scCtx.arc() wirft bei negativem Radius eine Exception, die -
2592  // unabgefangen im rAF-Callback - die gesamte Zeichenschleife fuer den Rest der Sitzung
2593  // stumm sterben liess (kein sichtbarer Fehler, das Radar blieb einfach leer). Bei
2594  // verstecktem/zu kleinem Container einfach nichts tun statt kaputte Masse zu setzen.
2595  if (wrap.clientWidth <= 0) return;
2596  // 14.08.2026 User-Wunsch ("passe den Scanner so an, dass er möglichst viel vom freien Bereich
2597  // nutzt"): die feste 420px-Obergrenze nahm auf großen Displays/im Querformat unnötig Platz weg
2598  // (Sektor blieb winzig trotz viel Luft drumherum) - Canvas füllt jetzt den kompletten
2599  // verfügbaren Wrapper aus (nur kleiner Rand für Range-Beschriftung/Touch-Ränder), keine
2600  // künstliche Maximalbreite mehr. scDrawRadarFrame() leitet den Sektor-Radius R ohnehin 
2600schon
2601  // dynamisch aus w/h ab, skaliert also automatisch mit.
2602  // 14.08.2026 Nachbesserung (User-Report mit Screenshot: "der Scanner beim Iphone viel zu weit
2603  // unten") - scDrawRadarFrame() begrenzt den Sektor-Radius R durch die BREITE (R = min(h-16,
2604  // w/2-6), Apex sitzt am unteren Rand). Bei schmalen Hochkant-Handys ist w/2 fast immer kleiner
2605  // als die vorher starre 58vh-Wrapper-Hoehe - der Faecher blieb klein UND haengte am unteren Rand
2606  // eines viel zu hohen, oben leeren Containers. Wrapper-Hoehe deshalb jetzt an die tatsaechlich
2607  // nutzbare Breite gekoppelt (Radius + Rand fuer Range-Beschriftung oben/Touch unten) statt an
2608  // eine feste vh-Vorgabe - die CSS-Werte (58vh/max 480px) bleiben nur noch als Obergrenze fuer
2609  // breite/Tablet-aehnliche Geraete erhalten.
2610  const wForRatio = wrap.clientWidth - 12;
2611  const idealH = Math.round(wForRatio / 2 + 46);
2612  wrap.style.height = Math.min(idealH, window.innerHeight * 0.58) + 'px';
2613  if (wrap.clientHeight <= 0) return;
2614  const w = wrap.clientWidth - 12;
2615  const h = wrap.clientHeight - 8;
2616  const dpr = window.devicePixelRatio || 1;
2617  scCanvas.style.width = w + 'px';
2618  scCanvas.style.height = h + 'px';
2619  scCanvas.width = w * dpr;
2620  scCanvas.height = h * dpr;
2621  scCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
2622  scCanvas._cssW = w; scCanvas._cssH = h;
2623}
2624window.addEventListener('resize', () => { if (activeView === 'scanner') scFitCanvas(); });
2625
2626let scBlipHitboxes = [];
2627function scDrawRadar() {
2628  if (!scDrawing) return; // View verlassen -> Loop stoppen (Akku)
2629  // 28.07.26 Bugfix: ein einzelner Fehler beim Zeichnen (z.B. durch eine kurzzeitig zu kleine
2630  // Canvas-Groesse) darf die rAF-Kette nicht abreissen lassen, sonst bleibt das Radar fuer den
2631  // Rest der Sitzung leer, ohne dass es im UI erkennbar waere - siehe scFitCanvas()-Kommentar.
2632  try {
2633    scDrawRadarFrame();
2634  } catch (e) { /* naechster Frame versucht es erneut */ }
2635  requestAnimationFrame(scDrawRadar);
2636}
2637function scDrawRadarFrame() {
2638  const w = scCanvas._cssW, h = scCanvas._cssH;
2639  if (!w || !h || w <= 0 || h <= 0) return;
2640  const ox = w / 2, oy = h - 6;
2641  const R = Math.max(1, Math.min(h - 16, w / 2 - 6));
2642  // 14.08.2026 User-Wunsch ("Linien noch etwas dicker und die Punkte dann auch, gerne
2643  // proportional zum Darstellungsbereich wachsend"): S ist der Skalierungsfaktor gegenüber dem
2644  // bisherigen typischen Radius (~150px bei der alten 420px-Deckelung) - alle Linienstärken/
2645  // Punktgrössen unten werden mit S multipliziert, wachsen also automatisch mit, wenn der
2646  // Scanner (siehe scFitCanvas()) mehr Platz bekommt, bleiben aber auf kleinen Screens nicht zu
2647  // duenn (untere Grenze 0.85) bzw. auf sehr grossen nicht grotesk dick (obere Grenze 2.2).
2648  const S = Math.max(0.85, Math.min(R / 150, 2.2));
2649  scCtx.clearRect(0, 0, w, h);
2650  const haveDir = scHeading !== null && scUserLat !== null;
2651
2652  scCtx.save();
2653  scCtx.beginPath();
2654  scCtx.moveTo(ox, oy);
2655  scCtx.arc(ox, oy, R, scToRad(-90 - SC_SECTOR_HALF), scToRad(-90 + SC_SECTOR_HALF));
2656  scCtx.closePath();
2657  const grad = scCtx.createRadialGradient(ox, oy, 0, ox, oy, R);
2658  grad.addColorStop(0, 'rgba(34,224,122,.16)');
2659  grad.addColorStop(1, 'rgba(34,224,122,.02)');
2660  scCtx.fillStyle = haveDir ? grad : 'rgba(120,120,120,.06)';
2661  scCtx.fill();
2662  scCtx.strokeStyle = 'rgba(34,224,122,.55)';
2663  scCtx.lineWidth = 4.5 * S;
2664  scCtx.stroke();
2665  scCtx.restore();
2666
2667  [1/3, 2/3, 1].forEach(f => {
2668    scCtx.beginPath();
2669    scCtx.arc(ox, oy, R * f, scToRad(-90 - SC_SECTOR_HALF), scToRad(-90 + SC_SECTOR_HALF));
2670    scCtx.strokeStyle = 'rgba(34,224,122,.22)';
2671    scCtx.lineWidth = (f === 1 ? 3 : 1.6) * S;
2672    scCtx.stroke();
2673    scCtx.fillStyle = 'rgba(180,220,200,.55)';
2674    scCtx.font = Math.round(9 * S) + 'px -apple-system, sans-serif';
2675    scCtx.textAlign = 'center';
2676    scCtx.fillText((scRangeKm * f).toFixed(0) + ' km', ox, oy - R * f - 4 * S);
2677  });
2678
2679  [-SC_SECTOR_HALF, SC_SECTOR_HALF].forEach(a => {
2680    const rad = scToRad(a - 90);
2681    scCtx.beginPath();
2682    scCtx.moveTo(ox, oy);
2683    scCtx.lineTo(ox + R * Math.cos(rad), oy + R * Math.sin(rad));
2684    scCtx.strokeStyle = 'rgba(34,224,122,.4)';
2685    scCtx.lineWidth = 3 * S;
2686    scCtx.stroke();
2687  });
2688
2689  if (haveDir) {
2690    scSweepAngle += scSweepDir * 1.4;
2691    if (scSweepAngle > SC_SECTOR_HALF) { scSweepAngle = SC_SECTOR_HALF; scSweepDir = -1; }
2692    if (scSweepAngle < -SC_SECTOR_HALF) { scSweepAngle = -SC_SECTOR_HALF; scSweepDir = 1; }
2693
2694    const TRAIL_DEG = 22, TRAIL_STEPS = 16;
2695    for (let i = TRAIL_STEPS; i >= 1; i--) {
2696      const t = i / TRAIL_STEPS;
2697      const a = scSweepAngle - scSweepDir * TRAIL_DEG * t;
2698      if (a < -SC_SECTOR_HALF || a > SC_SECTOR_HALF) continue;
2699      const trad = scToRad(a - 90);
2700      scCtx.beginPath();
2701      scCtx.moveTo(ox, oy);
2702      scCtx.lineTo(ox + R * Math.cos(trad), oy + R * Math.sin(trad));
2703      scCtx.strokeStyle = `rgba(34,224,122,${(1 - t) * 0.45})`;
2704      scCtx.lineWidth = 3 * S;
2705      scCtx.stroke();
2706    }
2707
2708    const rad = scToRad(scSweepAngle - 90);
2709    const trailGrad = scCtx.createLinearGradient(ox, oy, ox + R * Math.cos(rad), oy + R * Math.sin(rad));
2710    trailGrad.addColorStop(0, 'rgba(34,224,122,0)');
2711    trailGrad.addColorStop(1, 'rgba(34,224,122,.9)');
2712    scCtx.beginPath();
2713    scCtx.moveTo(ox, oy);
2714    scCtx.lineTo(ox + R * Math.cos(rad), oy + R * Math.sin(rad));
2715    scCtx.strokeStyle = trailGrad;
2716    scCtx.lineWidth = 3 * S;
2717    scCtx.stroke();
2718  }
2719
2720  scCtx.beginPath();
2721  scCtx.arc(ox, oy, 4 * S, 0, Math.PI * 2);
2722  scCtx.fillStyle = '#eafff2';
2723  scCtx.fill();
2724
2725  scBlipHitboxes = [];
2726  if (haveDir) {
2727    const inSector = scStationsInSector();
2728    const avg = inSector.length ? inSector.reduce((sum, x) => sum + stationPrice(x.s), 0) / inSector.length : null;
2729    inSector.forEach(x => {
2730      const r = Math.min(x.dist / scRangeKm, 1) * (R - 10) + 6;
2731      const rad = scToRad(x.rel - 90);
2732      const bx = ox + r * Math.cos(rad), by = oy + r * Math.sin(rad);
2733      const col = pinColor(stationPrice(x.s), avg);
2734      const isSel = scSelectedBlip === x.s.id;
2735      const blipR = (isSel ? 8 : 6) * S;
2736      scCtx.beginPath();
2737      scCtx.arc(bx, by, blipR, 0, Math.PI * 2);
2738      scCtx.fillStyle = col;
2739      scCtx.fill();
2740      if (isSel) { scCtx.strokeStyle = '#fff'; scCtx.lineWidth = 2.5 * S; scCtx.stroke(); }
2741      if (x.s.h24) {
2742        scCtx.beginPath();
2743        scCtx.arc(bx, by, (isSel ? 12 : 10) * S, 0, Math.PI * 2);
2744        scCtx.strokeStyle = 'rgba(255,255,255,.35)';
2745        scCtx.lineWidth = 1.5 * S;
2746        scCtx.stroke();
2747      }
2748      // Trefferflaeche fuer Taps waechst mit dem Punkt mit, sonst wird das Antippen auf grossen
2749      // Displays (grosse Punkte) unverhaeltnismaessig schwer bzw. bleibt auf kleinen zu grosszuegig.
2750      scBlipHitboxes.push({ x: bx, y: by, r: Math.max(14, blipR * 2), entry: x });
2751    });
2752  }
2753}
2754
2755scCanvas.addEventListener('click', e => {
2756  const rect = scCanvas.getBoundingClientRect();
2757  const mx = e.clientX - rect.left, my = e.clientY - rect.top;
2758  const hit = scBlipHitboxes.find(b => Math.hypot(b.x - mx, b.y - my) <= b.r);
2759  scShowBlipCard(hit ? hit.entry : null);
2760});
2761
2762function scShowBlipCard(entry) {
2763  const card = document.getElementById('sc-blip-card');
2764  if (!entry) { card.style.display = 'none'; scSelectedBlip = null; return; }
2765  scSelectedBlip = entry.s.id;
2766  const s = entry.s;
2767  const price = stationPrice(s);
2768  const oezText = s.h24 ? 'Durchgehend geöffnet (24h)' : (s.oez || '');
2769  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
2770  const isAndroid = /Android/.test(navigator.userAgent);
2771  const geoUrl = isIOS
2772    ? `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`
2773    : isAndroid
2774      ? `https://www.google.com/maps/dir/?api=1&destination=${s.lat},${s.lng}`
2775      : `https://maps.apple.com/?daddr=${s.lat},${s.lng}&dirflg=d`;
2776  card.innerHTML = `<span class="price">${price ? price.toFixed(3) + ' ' + priceUnit(s) : '–'}</span>
2777    <div class="name">${s.name}</div>
2778    <div class="meta">${s.strasse}, ${s.plz} ${s.ort} · ${entry.dist.toFixed(1)} km · ${entry.rel >= 0 ? entry.rel.toFixed(0)+'° rechts' : Math.abs(entry.rel).toFixed(0)+'° links'}</div>
2779    ${oezText ? `<div class="oez">🕒 ${oezText}</div>` : ''}
2780    <a href="${geoUrl}" class="nav-link">
2781      <svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
2782        <polygon points="3 11 22 2 13 21 11 13 3 11"/>
2783      </svg> Navigieren
2784    </a>`;
2785  card.style.display = 'block';
2786}
2787
2788function scRenderSectorList() {
2789  const rows = document.getElementById('sc-sector-rows');
2790  const empty = document.getElementById('sc-sector-empty');
2791  const list = scStationsInSector();
2792  if (!list.length) { rows.innerHTML = ''; empty.style.display = 'block'; return; }
2793  empty.style.display = 'none';
2794  const avg = list.reduce((sum, x) => sum + stationPrice(x.s), 0) / list.length;
2795  rows.innerHTML = list.slice(0, 12).map(x => {
2796    const price = stationPrice(x.s);
2797    return `<div class="sc-row" data-id="${x.s.id}">
2798      <div class="sc-dot" style="background:${pinColor(price, avg)}"></div>
2799      <div class="sc-name">${x.s.name}</div>
2800      <div class="sc-dist">${x.dist.toFixed(1)} km</div>
2801      <div class="sc-price">${price ? price.toFixed(3)+' '+priceUnit(x.s) : '–'}</div>
2802    </div>`;
2803  }).join('');
2804  rows.querySelectorAll('.sc-row').forEach(row => {
2805    row.addEventListener('click', () => {
2806      const entry = list.find(x => String(x.s.id) === row.dataset.id);
2807      scShowBlipCard(entry);
2808    });
2809  });
2810}
2811
2812function scUpdateStatus() {
2813  scUpdateCompassBtnVisibility();
2814  if (scUserLat === null) { scStatusEl.textContent = 'Warte auf Standort…'; scStatusEl.className = ''; return; }
2815  if (scHeading === null) {
2816    scStatusEl.textContent = '⏳ Bewege dich, damit die Fahrtrichtung erkannt wird — oder Kompass aktivieren.';
2817    scStatusEl.className = 'warn';
2818    return;
2819  }
2820  const src = scHeadingSource === 'gps' ? 'GPS-Kurs' : 'Kompass';
2821  scStatusEl.textContent = `🧭 ${src}: ${Math.round(scHeading)}°`;
2822  scStatusEl.className = 'ok';
2823}
2824
2825// ── Übersichtskarte ──
2826// 11.08.2026 User-Idee: optionaler "Groß"-Schalter fuer die Karte - bessere Lesbarkeit bei
2827// schlechten Sichtverhaeltnissen, dafuer weniger Tankstellen gleichzeitig im Bild. Zwei Effekte:
2828// (1) groessere Marker+Text per CSS-Regel auf ".mk-annotation-container" (siehe Style-Block
2829// oben, ".map-large-active") - erster Versuch war ein JS-Hack per ann.element.style.transform,
2830// der aber nach addAnnotations() nie zuverlaessig griff (User-Feedback "sehe kaum einen
2831// Unterschied"), daher auf reine CSS-Klasse umgestellt. (2) engerer Kartenausschnitt:
2832// showItems() bekommt auf User-Nachfrage ("dann auch den Kartenausschnitt verkleinern?") nur
2833// die naechsten paar Stationen statt aller - wer weiterpannt, findet die uebrigen trotzdem
2834// noch (sie werden nicht aus der Liste entfernt).
2835const MAP_LARGE_KEY = 'gasdb_map_large';
2836const MAP_LARGE_MAX_ITEMS = 6;
2837let mapLargeMode = localStorage.getItem(MAP_LARGE_KEY) === '1';
2838
2839function initOverviewMap() {
2840  if (!currentStations.length) return;
2841  setTimeout(() => {
2842    if (overviewMap) { overviewMap.destroy(); overviewMap = null; }
2843    overviewMap = new mapkit.Map('full-map', {
2844      showsCompass: mapkit.FeatureVisibility.Hidden,
2845      showsUserLocation: true,
2846      colorScheme: pwaMkColorScheme(),
2847    });
2848
2849    // 11.08.2026 (2. Korrektur): MapKit rendert normale MarkerAnnotation-Pins auf einem
2850    // WebGL/Canvas-Layer ("syrup-canvas") - im DOM existiert dafuer nur EIN unsichtbares
2851    // ".mk-annotation-container"-Element (Hit-Testing), egal wie viele Pins sichtbar sind.
2852    // CSS transform:scale() darauf aendert deshalb rein gar nichts am tatsaechlich gezeichneten
2853    // Bild (User-Feedback "keinerlei Aenderung" bestaetigt das). Fuer echte, garantiert
2854    // wirksame Groessenaenderung im Groß-Modus daher eigene DOM-basierte mapkit.Annotation
2855    // (Factory-Funktion liefert echtes <div>, MapKit positioniert es nur per CSS, zeichnet
2856    // nicht selbst) statt MarkerAnnotation - dort ist die Groesse direkt im Markup, kein Hack.
2857    const anns = currentStations
2858      .filter(s => s.lat && s.lng)
2859      .map(s => {
2860        if (mapLargeMode) {
2861          // 12.08.2026 Bugfix (User-Report: "beide Preise angezeigt, obwohl schon LPG
2862          // ausgewaehlt ist") - zeigte bisher immer LPG+CNG zusammen, auch wenn die Station
2863          // beide Preise hat, aber nur eine Gasart gerade ausgewaehlt (currentMode) ist.
2864          const priceTxt = currentMode === 'cng'
2865            ? (s.cng ? `${s.cng.toFixed(3)}${priceUnit(s)}` : '')
2866            : (s.lpg ? `${s.lpg.toFixed(3)}${priceUnit(s)}` : '');
2867          const color = pinColor(stationPrice(s), currentAvgPrice);
2868          const factory = () => {
2869            const div = document.createElement('div');
2870            div.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;';
2871            div.innerHTML = `
2872              <div style="background:${color};color:#fff;border-radius:50%;width:54px;height:54px;
2873                display:flex;align-items:center;justify-content:center;font-size:1.7rem;
2874                box-shadow:0 3px 10px rgba(0,0,0,.45);border:3px solid #fff;">${s.h24 ? '24' : '⛽'}</div>
2875              <div style="background:#fff;color:#222;border-radius:6px;padding:3px 9px;margin-top:3px;
2876                font-size:14px;font-weight:700;line-height:1.3;text-align:center;white-space:nowrap;
2877                box-shadow:0 2px 8px rgba(0,0,0,.35);">${esc(s.name)}<br>${esc(priceTxt)}</div>`;
2878            div.addEventListener('click', () => showDetail(s));
2879            return div;
2880          };
2881          const ann = new mapkit.Annotation(new mapkit.Coordinate(s.lat, s.lng), factory, {
2882            anchorOffset: new DOMPoint(0, -20),
2883            calloutEnabled: false,
2884          });
2885          return ann;
2886        }
2887        const ann = new mapkit.MarkerAnnotation(
2888          new mapkit.Coordinate(s.lat, s.lng),
2889          {
2890            title: s.name + (s.h24 ? ' (24h)' : ''),
2891            // 18.07.26: Gas-Art-Praefix vor dem Preis entfernt (User-Wunsch:
2892            // "die Gas-Art vor dem Preis ist ueberfluessig") - nur noch der
2893            // Preis, ohne "LPG"/"CNG"-Label.
2894            // 12.08.2026 Bugfix (User-Report: "beide Preise angezeigt, obwohl schon LPG
2895            // ausgewaehlt ist") - siehe gleicher Fix oben im Groß-Modus-Zweig.
2896            subtitle: currentMode === 'cng'
2897              ? (s.cng ? `${s.cng.toFixed(3)}${priceUnit(s)}` : '')
2898              : (s.lpg ? `${s.lpg.toFixed(3)}${priceUnit(s)}` : ''),
2899            // 18.07.26: Titel+Preis (subtitle) staendig sichtbar statt nur bei
2900            // Auswahl (User-Wunsch: "unter jedem Namen der Tankstelle den
2901            // aktuellen Preis anzeigen") - subtitle enthaelt den Preis.
2902            titleVisibility: mapkit.FeatureVisibility.Visible,
2903            subtitleVisibility: mapkit.FeatureVisibility.Visible,
2904            color: pinColor(stationPrice(s), currentAvgPrice), glyphText: s.h24 ? '24' : '⛽',
2905            callout: {
2906              calloutContentForAnnotation(ann) {
2907                const st = currentStations.find(x => x.lat == ann.coordinate.latitude && x.lng == ann.coordinate.longitude);
2908                const div = document.createElement('div');
2909                div.style.cssText = 'min-width:160px;font-size:13px;line-height:1.6';
2910                div.innerHTML = `<b>${esc(st.name)}</b><br>${esc(st.plz)} ${esc(st.ort)}<br>
2911                  <a href="#" onclick="event.preventDefault();showDetail(window._st_${st.id})"
2912                     style="color:#1D4CA3;font-weight:700">Details →</a>`;
2913                window[`_st_${st.id}`] = st;
2914                return div;
2915              }
2916            }
2917          }
2918        );
2919        return ann;
2920      });
2921    overviewMap.addAnnotations(anns);
2922    // 15.08.2026 User-Wunsch ("im Auto-Modus sollte der aktuelle Standort immer in der Mitte
2923    // sein") - showItems() zoomt/verschiebt die Karte bisher immer nur so, dass ALLE
2924    // Stations-Pins sichtbar sind (Bounding-Box ueber die Annotations) - liegen die Treffer
2925    // z.B. ueberwiegend noerdlich vom eigenen Standort, landet der eigene Positionspunkt am
2926    // Kartenrand statt mittig. Im Auto-Modus (waehrend der Fahrt) ist eine feste Zentrierung
2927    // auf die zuletzt gesuchte Position (= aktueller Standort, siehe fetchNearby()) wichtiger
2928    // als alle Treffer vollstaendig einzurahmen - Radius bestimmt den Zoom. Bei manueller Suche
2929    // (PLZ/Ort/Adresse) bleibt das bisherige "alle Treffer einrahmen"-Verhalten wie gewohnt.
2930    const fitAnns = mapLargeMode ? anns.slice(0, MAP_LARGE_MAX_ITEMS) : anns;
2931    if (mapAutoMode && lastSearchLat !== null && lastSearchLng !== null) {
2932      const r = parseFloat(document.getElementById('gps-radius')?.value) || 5;
2933      const spanDeg = Math.max(0.02, (r / 111) * 2.4); // grober km→Grad-Faktor + Rand
2934      overviewMap.region = new mapkit.CoordinateRegion(
2935        new mapkit.Coordinate(lastSearchLat, lastSearchLng),
2936        new mapkit.CoordinateSpan(spanDeg, spanDeg)
2937      );
2938    } else {
2939      overviewMap.showItems(fitAnns.length ? fitAnns : anns, { animate: false, padding: new mapkit.Padding(40,40,40,40) });
2940    }
2941  }, 80);
2942}
2943
2944function updateMapLargeToggleUI() {
2945  const btn = document.getElementById('map-large-toggle');
2946  if (btn) btn.classList.toggle('active', mapLargeMode);
2947}
2948document.getElementById('map-large-toggle')?.addEventListener('click', () => {
2949  mapLargeMode = !mapLargeMode;
2950  localStorage.setItem(MAP_LARGE_KEY, mapLargeMode ? '1' : '0');
2951  updateMapLargeToggleUI();
2952  // 12.08.2026 User-Wunsch: beim Einschalten des Groß-Modus automatisch auf 2,5 km stellen
2953  // (passt zu "weniger Tankstellen, besser lesbar") - normaler 5 km-Default bleibt sonst
2954  // unveraendert, beim Ausschalten wird der Radius nicht zurueckgesetzt.
2955  const radiusSel = document.getElementById('gps-radius');
2956  if (mapLargeMode && radiusSel) {
2957    radiusSel.value = '2.5';
2958    if (lastSearchLat !== null) {
2959      fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel).then(() => showView('map'));
2960      return;
2961    }
2962    updateMapRadiusUI(2.5);
2963  }
2964  if (activeView === 'map') initOverviewMap();
2965});
2966updateMapLargeToggleUI();
2967
2968// 11.08.2026 User-Idee: "Auto"-Modus fuer die Karte - waehrend der Fahrt Standort+Karte alle
2969// 5 Minuten automatisch aktualisieren, ohne manuell auf GPS tippen zu muessen. Timer laeuft
2970// NUR waehrend die Kartenansicht aktiv ist (siehe showView()) - spart Akku/GPS ausserhalb.
2971const MAP_AUTO_KEY = 'gasdb_map_auto';
2972const MAP_AUTO_INTERVAL_MS = 5 * 60 * 1000;
2973let mapAutoMode = localStorage.getItem(MAP_AUTO_KEY) === '1';
2974let mapAutoTimer = null;
2975
2976function updateMapAutoToggleUI() {
2977  const btn = document.getElementById('map-auto-toggle');
2978  if (btn) btn.classList.toggle('active', mapAutoMode);
2979}
2980function mapAutoRefresh() {
2981  if (activeView !== 'map' || !navigator.geolocation) return;
2982  navigator.geolocation.getCurrentPosition(
2983    pos => {
2984      // 11.08.2026 Bugfix (User-Report: "tippt man auf das Auto-Icon, wechselt er zur Liste") -
2985      // fetchNearby()/renderList() springen intern IMMER zur Listenansicht (siehe stepRadius()
2986      // oben, gleiches Problem+gleiche Loesung). Der frueher Return oben garantiert, dass wir
2987      // hier nur landen, wenn die Karte aktiv WAR - showView('map') danach holt sie zurueck
2988      // (ruft auch initOverviewMap() bereits selbst auf).
2989      fetchNearby(pos.coords.latitude, pos.coords.longitude).then(() => showView('map'));
2990    },
2991    () => {}, // Fehler beim automatischen Refresh bewusst still - kein Toast waehrend der Fahrt
2992    { timeout: 10000, enableHighAccuracy: true }
2993  );
2994}
2995function startMapAutoTracking() {
2996  stopMapAutoTracking();
2997  if (!mapAutoMode || activeView !== 'map') return;
2998  mapAutoTimer = setInterval(mapAutoRefresh, MAP_AUTO_INTERVAL_MS);
2999}
3000function stopMapAutoTracking() {
3001  if (mapAutoTimer) { clearInterval(mapAutoTimer); mapAutoTimer = null; }
3002}
3003document.getElementById('map-auto-toggle')?.addEventListener('click', () => {
3004  mapAutoMode = !mapAutoMode;
3005  localStorage.setItem(MAP_AUTO_KEY, mapAutoMode ? '1' : '0');
3006  updateMapAutoToggleUI();
3007  showMapToast(mapAutoMode ? '🚗 Auto-Folgen an' : 'Auto-Folgen aus'); // 11.08.2026 User-Wunsch
3008  // 15.08.2026 User-Wunsch ("bei 'Auto an' sollte er immer auf den 2,5km Radius springen") -
3009  // gleiches Muster wie beim Groß-Modus-Toggle oben: passt zu "wenige, gut lesbare Treffer
3010  // waehrend der Fahrt". Radius wird beim Ausschalten bewusst NICHT zurueckgesetzt (wie beim
3011  // Groß-Modus auch).
3012  const radiusSel = document.getElementById('gps-radius');
3013  if (mapAutoMode && radiusSel) {
3014    radiusSel.value = '2.5';
3015    updateMapRadiusUI(2.5);
3016  }
3017  if (mapAutoMode && activeView === 'map') { mapAutoRefresh(); startMapAutoTracking(); }
3018  else stopMapAutoTracking();
3019});
3020updateMapAutoToggleUI();
3021
3022// ── Langer Fingerdruck auf die Karte → neue Suche an dieser Position ──
3023// mapkit.Map kennt kein eigenes Long-Press-Event (nur select/region-change/...), daher
3024// per DOM-Touch-Timer auf den Karten-Container selbst gebaut (gleicher Grundansatz wie
3025// der Klick-Handler in new_map.php: convertPointOnPageToCoordinate() für Pixel→Koordinate).
3026(function () {
3027  const mapEl = document.getElementById('full-map');
3028  if (!mapEl) return;
3029  let pressTimer = null;
3030  let startX = 0, startY = 0;
3031  const MOVE_TOLERANCE = 10; // px, sonst zaehlt es als Kartenziehen statt Long-Press
3032
3033  mapEl.addEventListener('touchstart', (e) => {
3034    if (!overviewMap || e.touches.length !== 1) return;
3035    const t = e.touches[0];
3036    startX = t.pageX; startY = t.pageY;
3037    pressTimer = setTimeout(() => {
3038      pressTimer = null;
3039      const point = new DOMPoint(startX, startY);
3040      const c = overviewMap.convertPointOnPageToCoordinate(point);
3041      if (!c) return;
3042      if (navigator.vibrate) navigator.vibrate(30);
3043      showToast('Suche an dieser Position …');
3044      // 26.08.2026 Bugfix (User-Report: "Longpress auf die Karte soll danach auch wieder die
3045      // Karte anzeigen und nicht die Liste") - fetchNearby()/renderList() springen intern IMMER
3046      // zur Listenansicht (gleiches bereits bekanntes Problem wie beim "Auto"-Icon, siehe
3047      // mapAutoRefresh() oben, 11.08.2026). showView('map') danach holt die Kartenansicht zurueck.
3048      fetchNearby(c.latitude, c.longitude, 'Gewählte Position').then(() => showView('map'));
3049    }, 550);
3050  }, { passive: true });
3051
3052  mapEl.addEventListener('touchmove', (e) => {
3053    if (!pressTimer) return;
3054    const t = e.touches[0];
3055    if (Math.abs(t.pageX - startX) > MOVE_TOLERANCE || Math.abs(t.pageY - startY) > MOVE_TOLERANCE) {
3056      clearTimeout(pressTimer); pressTimer = null;
3057    }
3058  }, { passive: true });
3059
3060  ['touchend', 'touchcancel'].forEach(ev => mapEl.addEventListener(ev, () => {
3061    if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
3062  }, { passive: true }));
3063})();
3064
3065// ── API ──
3066async function fetchNearby(lat, lng, label) {
3067  lastSearchLat = lat; lastSearchLng = lng; lastSearchLabel = label;
3068  showLoader(true);
3069  try {
3070    // 18.07.26: Radius je Kraftstoffart als Vorbelegung (LPG-Stationen liegen
3071    // deutlich dichter als CNG), per Selektor neben "Meinen Standort nutzen"
3072    // oder den +/- Stufen-Buttons auf der Karte manuell ueberschreibbar (User-Wunsch).
3073    const radiusSel = document.getElementById('gps-radius');
3074    const r = radiusSel ? parseFloat(radiusSel.value) : (currentMode === 'cng' ? 20 : 5);
3075    updateMapRadiusUI(r);
3076    const url = `api/nearby.php?lat=${lat}&lng=${lng}&mode=${currentMode}&r=${r}${searchFilterParam()}`;
3077    const res = await fetch(url);
3078    const data = await res.json();
3079    if (!data.ok) throw new Error(data.error || 'Fehler');
3080    // 18.07.26: Suchradius statt Kraftstoffart im Listentitel (User
3080-Wunsch:
3081    // "In der Nähe (7,5km)" - LPG/CNG steht bereits im Mode-Tab, muss hier
3082    // nicht doppelt stehen).
3083    const rFmt = Number.isInteger(r) ? r : String(r).replace('.', ',');
3084    const title = label ? `${label} (${rFmt}km)` : `In der Nähe (${rFmt}km)`;
3085    renderList(data.stations, title);
3086    saveOfflineCache(data.stations, title);
3087    if (!data.stations.length) showToast(`Keine Tankstellen in ${r} km gefunden.`);
3088  } catch(e) {
3089    if (!(navigator.onLine === false && tryOfflineFallback())) {
3090      showToast('Fehler: ' + e.message);
3091    }
3092  } finally {
3093    showLoader(false);
3094  }
3095}
3096
3097const _pwaCountryCenters = {
3098  de: [51.1657, 10.4515], at: [47.5162, 14.5501], ch: [46.8182, 8.2275],
3099  nl: [52.1326, 5.2913],  be: [50.5039, 4.4699],  fr: [46.2276, 2.2137],
3100  pl: [51.9194, 19.1451], cz: [49.8175, 15.4730], lu: [49.8153, 6.1296],
3101};
3102async function fetchSearchByAddress(q) {
3103  return new Promise(resolve => {
3104    // 10.07.26: "XX-Ort"-Praefix (z.B. "NL-Soest") zwingt die Suche auf ein einzelnes Land,
3105    // sonst Deutschland als Schwerpunkt unter allen erlaubten Laendern.
3106    const m = q.match(/^([A-Za-z]{2})-(.+)$/);
3107    const forcedCountry = m && _pwaCountryCenters[m[1].toLowerCase()] ? m[1].toUpperCase() : null;
3108    if (forcedCountry) q = m[2].trim();
3109    const center = _pwaCountryCenters[(forcedCountry || 'de').toLowerCase()];
3110    const geocoder = new mapkit.Geocoder({ language: 'de-DE' });
3111    geocoder.lookup(q, async (err, result) => {
3112      if (err || !result.results || !result.results.length) {
3113        showToast('Adresse nicht gefunden – bitte PLZ oder Ort eingeben.');
3114        resolve(); return;
3115      }
3116      const { latitude, longitude } = result.results[0].coordinate;
3117      const place = result.results[0].formattedAddress || q;
3118      await fetchNearby(latitude, longitude, place);
3119      resolve();
3120    }, {
3121      limitToCountries: forcedCountry || 'DE,AT,CH,LU,BE,NL,FR,PL,CZ',
3122      region: new mapkit.CoordinateRegion(
3123          new mapkit.Coordinate(center[0], center[1]),
3124          new mapkit.CoordinateSpan(forcedCountry ? 3 : 6, forcedCountry ? 4 : 8)
3125      ),
3126    });
3127  });
3128}
3129
3130async function fetchSearch(q) {
3131  // 10.07.26: "!"-Praefix erzwingt die Geocoder-Suche direkt (z.B. "!Soest" oder
3132  // "!NL-Soest"), statt zuerst die lokale PLZ/Ort/Kennzeichen-Suche (api/search.php)
3133  // zu versuchen. Noetig, weil manche Orte (Land-Praefix) in der Tankstellen-DB nicht
3134  // ueber PLZ/Ort-Treffer aufgeloest werden koennen bzw. Mehrdeutigkeiten haben.
3135  if (q.startsWith('!')) {
3136    await fetchSearchByAddress(q.slice(1).trim());
3137    return;
3138  }
3139  // Adress-Erkennung: Komma ODER Straßen-Keywords → direkt geocodieren
3140  const looksLikeAddress = q.includes(',') ||
3141    /\b(str\.|straße|strasse|weg|allee|gasse|platz|ring|chaussee|damm|ufer|steig|promenade)\b/i.test(q);
3142  if (looksLikeAddress) {
3143    await fetchSearchByAddress(q);
3144    return;
3145  }
3146  showLoader(true);
3147  try {
3148    const url = `api/search.php?q=${encodeURIComponent(q)}&mode=${currentMode}&r=25${searchFilterParam()}`;
3149    const res = await fetch(url);
3150    const data = await res.json();
3151    // 10.07.26: Bugfix - "ok:false" (z.B. "Ort nicht gefunden" bei "NL-Soest") loeste
3152    // vorher sofort eine Exception aus und sprang NIE zum Geocoding-Fallback unten.
3153    // Jetzt: jedes Nicht-Treffer-Ergebnis (ok:false ODER leere stations) faellt gleich
3154    // auf die Adress-/Geocoder-Suche zurueck, echte Fehler (Netzwerk/JSON) landen weiter im catch.
3155    if (data.ok && data.stations && data.stations.length) {
3156      const titleSuffix = data.bezirk ? ` (${data.bezirk})` : '';
3157      const title = `${q}${titleSuffix} — ${currentMode.toUpperCase()}`;
3158      renderList(data.stations, title);
3159      saveOfflineCache(data.stations, title);
3160    } else {
3161      showToast('Kein PLZ/Ort gefunden – versuche Adress-Suche…');
3162      await fetchSearchByAddress(q);
3163    }
3164  } catch(e) {
3165    if (!(navigator.onLine === false && tryOfflineFallback())) {
3166      showToast('Fehler: ' + e.message);
3167    }
3168  } finally {
3169    showLoader(false);
3170  }
3171}
3172
3173// ── GPS ──
3174document.getElementById('btn-gps').addEventListener('click', () => {
3175  if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; }
3176  showLoader(true);
3177  navigator.geolocation.getCurrentPosition(
3178    pos => {
3179      userLat = pos.coords.latitude;
3180      userLng = pos.coords.longitude;
3181      showLoader(false);
3182      fetchNearby(userLat, userLng);
3183    },
3184    err => {
3185      showLoader(false);
3186      showToast('Standort nicht verfügbar: ' + err.message);
3187    },
3188    { timeout: 10000, enableHighAccuracy: true }
3189  );
3190});
3191
3192// 05.09.2026 (User-Idee: "vereinfachte PWA-URL um sofort auf die Detail-Ansicht der nächsten
3193// Tankstelle zu gelangen [...] fahre ich an einer Tankstelle vorbei und will nur kurz den
3194// angezeigten Preis aktualisieren") - per /mobile?q erreichbar (siehe Boot-Code unten): holt den
3195// Standort, fragt die naechstgelegene Station direkt per api/nearby.php ab (ORDER BY Entfernung
3196// ASC, siehe dortige Doku - erste Zeile ist die naechste) und springt direkt in deren
3197// Detailansicht, ohne den Umweg ueber Start/Suche/Liste.
3198async function quickJumpToNearest() {
3199  showLoader(true);
3200  if (!navigator.geolocation) {
3201    showLoader(false);
3202    showToast('GPS nicht verfügbar');
3203    showView('home');
3204    return;
3205  }
3206  navigator.geolocation.getCurrentPosition(async pos => {
3207    userLat = pos.coords.latitude;
3208    userLng = pos.coords.longitude;
3209    const r = currentMode === 'cng' ? 20 : 5;
3210    try {
3211      const res = await fetch(`api/nearby.php?lat=${userLat}&lng=${userLng}&mode=${currentMode}&r=${r}`);
3212      const data = await res.json();
3213      if (data.ok && data.stations && data.stations.length) {
3214        currentStations = data.stations;
3215        lastSearchLat = userLat; lastSearchLng = userLng; lastSearchLabel = 'In der Nähe';
3216        showDetail(data.stations[0]);
3217      } else {
3218        showToast(`Keine Tankstelle in ${r} km gefunden.`);
3219        showView('home');
3220      }
3221    } catch (e) {
3222      showToast('Fehler: ' + e.message);
3223      showView('home');
3224    } finally {
3225      showLoader(false);
3226    }
3227  }, err => {
3228    showLoader(false);
3229    showToast('Standort nicht verfügbar: ' + err.message);
3230    showView('home');
3231  }, { timeout: 10000, enableHighAccuracy: true });
3232}
3233
3234// ── Suche ──
3235document.getElementById('btn-search').addEventListener('click', () => {
3236  const q = searchInput.value.trim();
3237  if (!q) { showToast('Bitte PLZ, Ort, Adresse, Kennzeichen oder Autobahn eingeben'); return; }
3238  fetchSearch(q);
3239});
3240searchInput.addEventListener('keydown', e => {
3241  if (e.key === 'Enter') document.getElementById('btn-search').click();
3242});
3243
3244// ── Mode-Tabs ──
3245// Aktiven Mode-Button beim Start setzen
3246function setMode(mode) {
3247  if (!['lpg','cng','all'].includes(mode)) return;
3248  currentMode = mode;
3249  localStorage.setItem('gasdb_mode', currentMode);
3250  document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
3251  // 18.07.26: Radius-Selektor beim Kraftstoff-Wechsel auf sinnvollen Default setzen
3252  const radiusSel = document.getElementById('gps-radius');
3253  if (radiusSel) radiusSel.value = mode === 'cng' ? '20' : '5';
3254}
3255
3256setMode(currentMode);
3257
3258document.querySelectorAll('.mode-btn').forEach(btn => {
3259  btn.addEventListener('click', () => {
3260    setMode(btn.dataset.mode);
3261    // Neu laden wenn schon Ergebnisse da
3262    if (currentStations.length || activeView === 'list') {
3263      if (userLat) fetchNearby(userLat, userLng);
3264      else { const q = searchInput.value.trim(); if (q) fetchSearch(q); }
3265    }
3266  });
3267});
3268
3269// 28.08.2026 (PLAN Tankbuch): Login-Gate ist hier nur kosmetisch (zeigt Hinweis statt Formular) -
3270// der eigentliche Schutz liegt serverseitig in mobile/api/tankbuch.php (prueft $userindex).
3271function showTankbuchView() {
3272  showView('tankbuch');
3273  const hint = document.getElementById('tankbuch-login-hint');
3274  const content = document.getElementById('tankbuch-content');
3275  if (currentUser) {
3276    hint.style.display = 'none';
3277    content.style.display = '';
3278    // 28.08.2026 (User-Wunsch: "die PWA nur zur Eingabe/Kontrolle der letzten 3 Tankvorgaenge,
3279    // Bearbeiten auf die Website verweisen") - eingeschraenkte Optionen fuer den PWA-Kontext,
3280    // siehe initTankbuch(opts) in js/tankbuch.js. Desktop (tankbuch.php) ruft ohne Optionen auf
3281    // = volle Darstellung (kein Limit, Bearbeiten erlaubt).
3282    // allowVehicleManage:false (Zweitfahrzeug-Erweiterung, User-Wunsch: "Bezeichner lasse
3282n sich
3283    // nur auf der Website anpassen um das Interface der PWA minimal zu halten") - Fahrzeuge
3284    // anlegen/umbenennen/loeschen geht nur ueber tankbuch.php, die PWA zeigt nur die Auswahl.
3285    if (window.initTankbuch) initTankbuch({ limit: 3, allowEdit: false, moreLink: '/tankbuch.php', allowVehicleManage: false });
3286  } else {
3287    hint.style.display = '';
3288    content.style.display = 'none';
3289  }
3290}
3291
3292// ── Bottom Nav ──
3293document.getElementById('btn-open-help')?.addEventListener('click', () => showView('help'));
3294document.getElementById('btn-close-help')?.addEventListener('click', () => showView('home'));
3295
3296document.querySelectorAll('.nav-btn').forEach(btn => {
3297  btn.addEventListener('click', () => {
3298    // 05.09.2026: ein bewusster Wechsel ins normale Bottom-Nav beendet den Quick-Jump-Loop
3299    // (siehe _quickJump/showQuickJumpAgain oben) - sonst wuerde ein spaeteres Preis-Update in der
3300    // normalen App-Navigation ueberraschend wieder das gruene Wiederholungs-Overlay zeigen.
3301    _quickJump = false;
3302    const target = btn.dataset.view;
3303    if (target === 'route')   { showView('route');   return; }
3304    if (target === 'scanner') { showView('scanner'); return; }
3305    if (target === 'profile') { showView('profile'); return; }
3306    if (target === 'tankbuch') { showTankbuchView(); return; }
3307    if (target === 'list' && currentStations.length) { showView('list'); return; }
3308    if (target === 'map'  && currentStations.length) { showView('map');  return; }
3309    showView('home');
3310  });
3311});
3312
3313// ── Langer Fingerdruck auf "Liste"/"Karte" im Bottom-Nav → Umkreissuche am aktuellen
3314// GPS-Standort (User-Idee) - gleicher Grundansatz (Touch-Timer, 550ms, Vibration) wie der
3315// bestehende Long-Press auf der Karte selbst (siehe "Langer Fingerdruck auf die Karte" oben),
3316// nutzt aber navigator.geolocation statt einer angetippten Kartenposition, da ein Nav-Button
3317// keine Koordinate hat. Bei "Liste" UND "Karte" wird die Such-/Modusleiste au
3317sgeblendet
3318// ("maximale Screensize" - User-Wunsch), analog zur bestehenden Route-/Scanner-View-Behandlung
3319// in showView(). Ein anschliessender Klick nach ausgeloestem Long-Press wird unterdrueckt, sonst
3320// wuerde der normale Klick-Handler oben direkt danach nochmal (ungewollt) navigieren.
3321(function () {
3322  document.querySelectorAll('.nav-btn').forEach(btn => {
3323    const view = btn.dataset.view;
3324    if (view !== 'list' && view !== 'map') return;
3325    let pressTimer = null;
3326    let longPressed = false;
3327
3328    btn.addEventListener('touchstart', () => {
3329      longPressed = false;
3330      pressTimer = setTimeout(() => {
3331        pressTimer = null;
3332        longPressed = true;
3333        if (navigator.vibrate) navigator.vibrate(30);
3334        if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; }
3335        showLoader(true);
3336        navigator.geolocation.getCurrentPosition(
3337          async pos => {
3338            userLat = pos.coords.latitude;
3339            userLng = pos.coords.longitude;
3340            showLoader(false);
3341            await fetchNearby(userLat, userLng);
3342            showView(view);
3343            // 11.08.2026 User-Wunsch: "bei der Karte dann auch den maximalen Bereich nutzen" -
3344            // gleiche Behandlung wie bei "Liste" oben, auch fuer die Karten-View.
3345            const searchBar = document.getElementById('search-bar');
3346            const modeBar   = document.getElementById('mode-bar');
3347            if (searchBar) searchBar.style.display = 'none';
3348            document.getElementById('search-filter-panel').style.display = 'none';
3349            if (modeBar)   modeBar.style.display   = 'none';
3350          },
3351          err => {
3352            showLoader(false);
3353            showToast('Standort nicht verfügbar: ' + err.message);
3354          },
3355          { timeout: 10000, enableHighAccuracy: true }
3356        );
3357      }, 550);
3358    }, { passive: true });
3359
3360    ['touchend', 'touchcancel', 'touchmove'].forEach(ev => btn.addEventListener(ev, () => {
3361      if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
3362    }, { passive: true }));
3363
3364    btn.addEventListener('click', (e) => {
3365      if (longPressed) { e.preventDefault(); e.stopPropagation(); longPressed = false; }
3366    }, true);
3367  });
3368})();
3369
3370// ── Radius-Stufen auf der Karte (+/-) ──
3371// 18.07.26: User-Wunsch - direkt auf der Karte zur naechsten/vorherigen
3372// Radius-Stufe springen, statt zurueck zur Startseite zum Selektor zu muessen.
3373function updateMapRadiusUI(r) {
3374  const label = document.getElementById('map-radius-label');
3375  if (label) label.textContent = (Number.isInteger(r) ? r : String(r).replace('.', ',')) + ' km';
3376  const idx = RADIUS_STEPS.indexOf(r);
3377  const minusBtn = document.getElementById('map-radius-minus');
3378  const plusBtn  = document.getElementById('map-radius-plus');
3379  if (minusBtn) minusBtn.disabled = (idx <= 0);
3380  if (plusBtn)  plusBtn.disabled  = (idx < 0 || idx >= RADIUS_STEPS.length - 1);
3381}
3382
3383function stepRadius(delta) {
3384  const radiusSel = document.getElementById('gps-radius');
3385  if (!radiusSel) return;
3386  const idx = RADIUS_STEPS.indexOf(parseFloat(radiusSel.value));
3387  const newIdx = idx + delta;
3388  if (newIdx < 0 || newIdx >= RADIUS_STEPS.length) return;
3389  radiusSel.value = String(RADIUS_STEPS[newIdx]);
3390  if (lastSearchLat !== null) {
3391    // renderList() springt sonst immer zur Listenansicht - beim Stufen-Wechsel
3392    // direkt auf der Karte soll die Karte aber sichtbar bleiben.
3393    fetchNearby(lastSearchLat, lastSearchLng, lastSearchLabel).then(() => showView('map'));
3394  } else {
3395    updateMapRadiusUI(RADIUS_STEPS[newIdx]);
3396  }
3397}
3398
3399document.getElementById('map-radius-minus').addEventListener('click', () => stepRadius(-1));
3400document.getElementById('map-radius-plus').addEventListener('click', () => stepRadius(1));
3401
3402// ══════════════════════════════════════════════
3403// ── ROUTENPLANER ──
3404// ══════════════════════════════════════════════
3405
3406let routeMode      = localStorage.getItem('routeMode')      || 'lpg';
3407let routeRange     = parseInt(localStorage.getItem('routeRange')     || '350');
3408// 18.09.2026 Merkmals-Filter der Routenansicht (siehe #route-filter-box), per localStorage gemerkt
3409function routeFilters() {
3410  return [...document.querySelectorAll('.route-filter-cb:checked')].map(cb => cb.value);
3411}
3412function routeUpdateFilterCount() {
3413  const n = routeFilters().length;
3414  document.getElementById('route-filter-count').textContent = n ? '(' + n + ')' : '';
3415  try { localStorage.setItem('routeFilters', JSON.stringify(routeFilters())); } catch (e) {}
3416}
3417document.getElementById('btn-route-filter-toggle').addEventListener('click', () => {
3418  const box = document.getElementById('route-filter-box');
3419  box.style.display = box.style.display === 'none' ? 'block' : 'none';
3420});
3421try {
3422  const savedF = JSON.parse(localStorage.getItem('routeFilters') || '[]');
3423  document.querySelectorAll('.route-filter-cb').forEach(cb => { cb.checked = savedF.includes(cb.value); });
3424} catch (e) {}
3425document.querySelectorAll('.route-filter-cb').forEach(cb => cb.addEventListener('change', routeUpdateFilterCount));
3426routeUpdateFilterCount();
3427
3428let routeCorridor  = parseInt(localStorage.getItem('routeCorridor')  || '15');
3429let routeStartCoord = null;
3430let routeEndCoord   = null;
3431routeMap        = null;
3432let routeAvoidMotorway = false;
3433let routeWaypoints = [];
3434let wpSelectedCoord = null;
3435let routeStations   = [];
3436
3437// Initialwerte ins Formular schreiben
3438document.getElementById('route-range').value    = routeRange;
3439document.getElementById('route-corridor').value = routeCorridor;
3440document.querySelectorAll('.route-mode-btn').forEach(b =>
3441  b.classList.toggle('active', b.dataset.rmode === routeMode));
3442
3443// Gasart-Auswahl im Route-Formular
3444document.querySelectorAll('.route-mode-btn').forEach(btn => {
3445  btn.addEventListener('click', () => {
3446    document.querySelectorAll('.route-mode-btn').forEach(b => b.classList.remove('active'));
3447    btn.classList.add('active');
3448    routeMode = btn.dataset.rmode;
3449    localStorage.setItem('routeMode', routeMode);
3450  });
3451});
3452
3453// ── Adress-Autocomplete ──
3454const _routeSearch = new mapkit.Search({ language: 'de' });
3455
3456function setupRouteAutocomplete(inputEl, suggEl, onSelect) {
3457  let timer = null, req = null;
3458  inputEl.addEventListener('input', () => {
3459    clearTimeout(timer);
3460    const q = inputEl.value.trim();
3461    if (q.length < 3) { suggEl.classList.remove('show'); return; }
3462    timer = setTimeout(() => {
3463      if (req) _routeSearch.cancel(req);
3464      req = _routeSearch.autocomplete(q, (err, data) => {
3465        if (err || !data.results.length) { suggEl.classList.remove('show'); return; }
3466        const items = data.results.slice(0, 6);
3467        suggEl.innerHTML = items.map((r, i) =>
3468          `<div class="route-sugg-item" data-i="${i}">
3469            <div class="route-sugg-main">${esc(r.displayLines[0] || '')}</div>
3470            ${r.displayLines[1] ? `<div class="route-sugg-sub">${esc(r.displayLines[1])}</div>` : ''}
3471          </div>`
3472        ).join('');
3473        suggEl.classList.add('show');
3474        suggEl.querySelectorAll('.route-sugg-item').forEach(el => {
3475          el.addEventListener('pointerdown', e => {
3476            e.preventDefault();
3477            const r = items[+el.dataset.i];
3478            if (!r.coordinate) { suggEl.classList.remove('show'); return; }
3479            inputEl.value = r.displayLines[0];
3480            suggEl.classList.remove('show');
3481            onSelect(new mapkit.Coordinate(r.coordinate.latitude, r.coordinate.longitude));
3482          });
3483        });
3484      });
3485    }, 320);
3486  });
3487  inputEl.addEventListener('blur', () => setTimeout(() => suggEl.classList.remove('show'), 150));
3488}
3489
3490setupRouteAutocomplete(
3491  document.getElementById('route-start-input'),
3492  document.getElementById('route-start-sugg'),
3493  coord => { routeStartCoord = coord; }
3494);
3495setupRouteAutocomplete(
3496  document.getElementById('route-end-input'),
3497  document.getElementById('route-end-sugg'),
3498  coord => { routeEndCoord = coord; }
3499);
3500
3501// GPS als Start
3502document.getElementById('btn-gps-route-inline').addEventListener('click', () => {
3503  if (!navigator.geolocation) { showToast('GPS nicht verfügbar'); return; }
3504  navigator.geolocation.getCurrentPosition(pos => {
3505    routeStartCoord = new mapkit.Coordinate(pos.coords.latitude, pos.coords.longitude);
3506    document.getElementById('route-start-input').value = '📍 Mein Standort';
3507  }, () => showToast('Standort nicht ermittelt'));
3508});
3509
3510// ── Zwischenstopps ──
3511function renderWaypointList() {
3512  const list   = document.getElementById('route-wp-list');
3513  const addBtn = document.getElementById('btn-add-wp');
3514  if (!routeWaypoints.length) {
3515    list.style.display = 'none';
3516    list.innerHTML = '';
3517  } else {
3518    list.style.display = 'block';
3519    list.innerHTML = routeWaypoints.map((wp, i) =>
3520      '<div class="wp-item">' +
3521        '<span class="wp-num">' + (i + 1) + '</span>' +
3522        '<span class="wp-label">' + esc(wp.label) + '</span>' +
3523        '<button class="wp-del" data-i="' + i + '" aria-label="Entfernen">✕</button>' +
3524      '</div>'
3525    ).join('');
3526    list.querySelectorAll('.wp-del').forEach(function(btn) {
3527      btn.addEventListener('click', function() {
3528        routeWaypoints.splice(+btn.dataset.i, 1);
3529        renderWaypointList();
3530      });
3531    });
3532  }
3533  addBtn.style.display = routeWaypoints.length >= 5 ? 'none' : '';
3534}
3535
3536function openWpSheet() {
3537  document.getElementById('wp-backdrop').style.display = 'block';
3538  document.getElementById('wp-sheet').classList.add('wp-open');
3539  document.getElementById('wp-input').value = '';
3540  document.getElementById('wp-add-btn').disabled = true;
3541  wpSelectedCoord = null;
3542  setTimeout(function() { document.getElementById('wp-input').focus(); }, 280);
3543}
3544
3545function closeWpSheet() {
3546  document.getElementById('wp-backdrop').style.display = 'none';
3547  document.getElementById('wp-sheet').classList.remove('wp-open');
3548  document.getElementById('wp-sugg').classList.remove('show');
3549}
3550
3551document.getElementById('btn-add-wp').addEventListener('click', openWpSheet);
3552document.getElementById('wp-backdrop').addEventListener('click', closeWpSheet);
3553document.getElementById('wp-sheet-close').addEventListener('click', closeWpSheet);
3554document.getElementById('wp-add-btn').addEventListener('click', function() {
3555  if (!wpSelectedCoord) return;
3556  routeWaypoints.push({ coord: wpSelectedCoord, label: document.getElementById('wp-input').value });
3557  closeWpSheet();
3558  renderWaypointList();
3559});
3560setupRouteAutocomplete(
3561  document.getElementById('wp-input'),
3562  document.getElementById('wp-sugg'),
3563  function(coord) {
3564    wpSelectedCoord = coord;
3565    document.getElementById('wp-add-btn').disabled = false;
3566  }
3567);
3568
3569// ── Autobahn-Toggle ──
3570document.getElementById('route-avoid-motorway').addEventListener('change', function() {
3571  routeAvoidMotorway = this.checked;
3572});
3573
3574// ── Algorithmus-Hilfsfunktionen ──
3575function haversineKm(lat1, lng1, lat2, lng2) {
3576  const R = 6371, r = Math.PI / 180;
3577  const dLat = (lat2 - lat1) * r, dLng = (lng2 - lng1) * r;
3578  const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLng/2)**2;
3579  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
3580}
3581
3582// Geradlinige Interpolation als Fallback wenn polyline.points leer
3583function interpolateGreatCircle(startC, endC, n) {
3584  const pts = [];
3585  for (let i = 0; i <= n; i++) {
3586    const t = i / n;
3587    pts.push({ latitude: startC.latitude + t*(endC.latitude-startC.latitude),
3588               longitude: startC.longitude + t*(endC.longitude-startC.longitude) });
3589  }
3590  return pts;
3591}
3592
3593function buildCumDist(coords) {
3594  const cum = [0];
3595  for (let i = 1; i < coords.length; i++)
3596    cum.push(cum[i-1] + haversineKm(coords[i-1].latitude, coords[i-1].longitude,
3597                                     coords[i].latitude,   coords[i].longitude));
3598  return cum;
3599}
3600
3601function sampleRoute(coords, cumDist, stepKm) {
3602  const pts = [{ lat: coords[0].latitude, lng: coords[0].longitude }];
3603  let next = stepKm;
3604  for (let i = 1; i < cumDist.length; i++) {
3605    if (cumDist[i] >= next) {
3606      pts.push({ lat: coords[i].latitude, lng: coords[i].longitude });
3607      next += stepKm;
3608    }
3609  }
3610  const last = coords[coords.length - 1];
3611  pts.push({ lat: last.latitude, lng: last.longitude });
3612  return pts;
3613}
3614
3615function getRouteKm(s, coords, cumDist) {
3616  let minD = Infinity, bestI = 0;
3617  for (let i = 0; i < coords.length; i++) {
3618    const d = haversineKm(s.lat, s.lng, coords[i].latitude, coords[i].longitude);
3619    if (d < minD) { minD = d; bestI = i; }
3620  }
3621  return cumDist[bestI];
3622}
3623
3624// Greedy: minimale Stopp-Anzahl
3625function findOptimalStops(stations, totalKm, effRange) {
3626  if (effRange <= 0) return { stops: [], error: 'Effektive Reichweite nach Reserve zu gering.' };
3627  const stops = [];
3628  let pos = 0;
3629  while (pos + effRange < totalKm) {
3630    const reach = stations.filter(s => s.routeKm > pos && s.routeKm <= pos + effRange);
3631    if (!reach.length)
3632      return { stops, error: `Keine Tankstelle erreichbar ab km ${Math.round(pos)} (Reichweite bis km ${Math.round(pos + effRange)}).` };
3633    // Immer die am weitesten entfernte Station nehmen (Greedy → minimale Stopp-Anzahl)
3634    stops.push(reach[reach.length - 1]);
3635    pos = reach[reach.length - 1].routeKm;
3636  }
3637  return { stops, error: null };
3638}
3639
3640// ── Routing: OSRM (normal) / Valhalla (Autobahn vermeiden) ──
3641function decodePolyline(str) {
3642  let i = 0, lat = 0, lng = 0, coords = [];
3643  while (i < str.length) {
3644    let b, shift = 0, result = 0;
3645    do { b = str.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
3646    lat += (result & 1) ? ~(result >> 1) : (result >> 1);
3647    shift = result = 0;
3648    do { b = str.charCodeAt(i++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
3649    lng += (result & 1) ? ~(result >> 1) : (result >> 1);
3650    coords.push(new mapkit.Coordinate(lat / 1e6, lng / 1e6));
3651  }
3652  return coords;
3653}
3654
3655async function calcRouteOSRM(coords, avoidMotorway) {
3656  if (!avoidMotorway) {
3657    const waypts = coords.map(c => c.longitude + ',' + c.latitude).join(';');
3658    const res  = await fetch('https://router.project-osrm.org/route/v1/driving/' + waypts + '?geometries=geojson&overview=full');
3659    const data = await res.json();
3660    if (!data.routes || !data.routes.length) throw new Error('Keine Route gefunden');
3661    const route  = data.routes[0];
3662    const points = route.geometry.coordinates.map(c => new mapkit.Coordinate(c[1], c[0]));
3663    return { points, distanceKm: route.distance / 1000, durationSecs: route.duration };
3664  }
3665  const locations = coords.map(c => ({ lon: c.longitude, lat: c.latitude }));
3666  const res  = await fetch('https://valhalla1.openstreetmap.de/route', {
3667    method: 'POST', headers: { 'Content-Type': 'application/json' },
3668    body: JSON.stringify({ locations, costing: 'auto', costing_options: { auto: { use_highways: 0.0 } } })
3669  });
3670  const data = await res.json();
3671  if (!data.trip || !data.trip.legs || !data.trip.legs.length) throw new Error('Keine Route gefunden (Valhalla)');
3672  let allPoints = [], totalKm = 0, totalSecs = 0;
3673  data.trip.legs.forEach(leg => {
3674    const pts = decodePolyline(leg.shape);
3675    allPoints = allPoints.length ? [...allPoints, ...pts.slice(1)] : [...pts];
3676    totalKm += leg.summary.length; totalSecs += leg.summary.time;
3677  });
3678  return { points: allPoints, distanceKm: totalKm, durationSecs: totalSecs };
3679}
3680
3681// ── Route berechnen ──
3682document.getElementById('btn-route-calc').addEventListener('click', async () => {
3683  if (!routeStartCoord) { showToast('Bitte Start eingeben oder Standort nutzen'); return; }
3684  if (!routeEndCoord)   { showToast('Bitte Ziel eingeben'); return; }
3685
3686  routeRange    = Math.max(50, Math.min(800, parseInt(document.getElementById('route-range').value)    || 350));
3687  routeCorridor = Math.max(5,  Math.min(30,  parseInt(document.getElementById('route-corridor').value) || 15));
3688  localStorage.setItem('routeRange',    routeRange);
3689  localStorage.setItem('routeCorridor', routeCorridor);
3690
3691  const btn = document.getElementById('btn-route-calc');
3692  btn.disabled = true; btn.innerHTML = '<span style="display:inline-block;width:17px;height:17px;border:2.5px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:route-spin .7s linear infinite;vertical-align:middle;margin-right:8px"></span>Berechne…';
3693
3694  try {
3695    const allCoords = [routeStartCoord, ...routeWaypoints.map(w => w.coord), routeEndCoord];
3696    const { points: useCoords, distanceKm: totalKm, durationSecs: totalSecs } =
3697      await calcRouteOSRM(allCoords, routeAvoidMotorway);
3698
3699    const cumDist  = buildCumDist(useCoords);
3700    const stepKm   = Math.max(routeCorridor * 1.5, 10);
3701    const waypoints = sampleRoute(useCoords, cumDist, stepKm);
3702
3703    let apiData;
3704    try {
3705      const res = await fetch('/mobile/api/route.php', {
3706        method: 'POST',
3707        headers: { 'Content-Type': 'application/json' },
3708        body: JSON.stringify({ waypoints, corridor_km: routeCorridor, mode: routeMode, filters: routeFilters() })
3709      });
3710      apiData = await res.json();
3711    } catch(e) { showToast('Verbindungsfehler'); return; }
3712
3713    if (!apiData.ok) { showToast('API-Fehler: ' + (apiData.error || '?')); return; }
3714
3715    routeStations = apiData.stations.map(s => ({ ...s, routeKm: getRouteKm(s, useCoords, cumDist) }));
3716    routeStations.sort((a, b) => a.routeKm - b.routeKm);
3717
3718    const { stops, error } = findOptimalStops(routeStations, totalKm, routeRange - 50);
3719    const nearStops = stops.length
3720      ? routeStations.filter(s => stops.some(st => haversineKm(s.lat, s.lng, st.lat, st.lng) <= routeCorridor))
3721      : routeStations;
3722    renderRouteResults(useCoords, nearStops, stops, totalKm, totalSecs, error);
3723    saveRouteHistory();
3724  } catch(e) {
3725    showToast('Route konnte nicht berechnet werden');
3726  } finally {
3727    btn.disabled = false; btn.textContent = 'Route berechnen 🔍';
3728  }
3729});
3730
3731// ── Routenverlauf ──
3732let routeHistoryCache = [];
3733
3734async function loadRouteHistory() {
3735  if (!currentUser) { renderRouteHistoryChips([]); return; }
3736  try {
3737    const r = await fetch('/mobile/api/route_history.php');
3738    const d = await r.json();
3739    routeHistoryCache = (d.ok && d.routes) ? d.routes : [];
3740  } catch(e) { routeHistoryCache = []; }
3741  renderRouteHistoryChips(routeHistoryCache);
3742}
3743
3744function renderRouteHistoryChips(routes) {
3745  const row   = document.getElementById('route-history-row');
3746  const chips = document.getElementById('rh-chips');
3747  if (!routes.length) { row.classList.remove('has-items'); return; }
3748  row.classList.add('has-items');
3749  chips.innerHTML = routes.map(r => {
3750    const via = r.waypoints && r.waypoints.length
3751      ? ' <span style="color:var(--sub)">via ' + esc(r.waypoints.map(w=>w.label).join(', ')) + '</span>' : '';
3752    const label = esc(r.start.label) + ' → ' + esc(r.end.label);
3753    return '<div class="rh-chip" data-id="' + r.id + '">' +
3754      '<span class="rh-chip-text" title="' + label + '">' + label + via + '</span>' +
3755      '<button class="rh-chip-del" data-id="' + r.id + '" aria-label="Löschen">✕</button>' +
3756    '</div>';
3757  }).join('');
3758  chips.querySelectorAll('.rh-chip').forEach(chip => {
3759    chip.addEventListener('click', function(e) {
3760      if (e.target.classList.contains('rh-chip-del')) return;
3761      const id = +chip.dataset.id;
3762      const route = routes.find(r => r.id === id);
3763      if (route) applyHistoryRoute(route);
3764    });
3765  });
3766  chips.querySelectorAll('.rh-chip-del').forEach(btn => {
3767    btn.addEventListener('click', async function(e) {
3768      e.stopPropagation();
3769      const id = +btn.dataset.id;
3770      await fetch('/mobile/api/route_history.php', {
3771        method: 'DELETE', headers: {'Content-Type':'application/json'},
3772        body: JSON.stringify({id})
3773      });
3774      routeHistoryCache = routeHistoryCache.filter(r => r.id !== id);
3775      renderRouteHistoryChips(routeHistoryCache);
3776    });
3777  });
3778}
3779
3780function applyHistoryRoute(route) {
3781  document.getElementById('route-start-input').value = route.start.label;
3782  routeStartCoord = new mapkit.Coordinate(route.start.lat, route.
3782start.lng);
3783  document.getElementById('route-end-input').value = route.end.label;
3784  routeEndCoord = new mapkit.Coordinate(route.end.lat, route.end.lng);
3785  routeWaypoints = (route.waypoints || []).map(wp => ({
3786    label: wp.label,
3787    coord: new mapkit.Coordinate(wp.lat, wp.lng)
3788  }));
3789  renderWaypointList();
3790  routeAvoidMotorway = route.avoidMotorway || false;
3791  document.getElementById('route-avoid-motorway').checked = routeAvoidMotorway;
3792}
3793
3794function routesIdentical(a, b) {
3795  if (!a || !b) return false;
3796  const c3 = v => Math.round(v * 1000);
3797  if (c3(a.start.lat) !== c3(b.start.lat) || c3(a.start.lng) !== c3(b.start.lng)) return false;
3798  if (c3(a.end.lat)   !== c3(b.end.lat)   || c3(a.end.lng)   !== c3(b.end.lng))   return false;
3799  if ((a.waypoints||[]).length !== (b.waypoints||[]).length) return false;
3800  if (!!a.avoidMotorway !== !!b.avoidMotorway) return false;
3801  return (a.waypoints||[]).every((w,i) => {
3802    const v = b.waypoints[i];
3803    return c3(w.lat) === c3(v.lat) && c3(w.lng) === c3(v.lng);
3804  });
3805}
3806
3807async function saveRouteHistory() {
3808  if (!currentUser || !routeStartCoord || !routeEndCoord) return;
3809  const route = {
3810    start:         { label: document.getElementById('route-start-input').value,
3811                     lat: routeStartCoord.latitude, lng: routeStartCoord.longitude },
3812    end:           { label: document.getElementById('route-end-input').value,
3813                     lat: routeEndCoord.latitude, lng: routeEndCoord.longitude },
3814    waypoints:     routeWaypoints.map(w => ({
3815                     label: w.label,
3816                     lat: w.coord.latitude, lng: w.coord.longitude })),
3817    avoidMotorway: routeAvoidMotorway
3818  };
3819  if (routeHistoryCache.length && routesIdentical(route, routeHistoryCache[0])) return;
3820  try {
3821    await fetch('/mobile/api/route_history.php', {
3822      method: 'POST', headers: {'Content-Type':'application/json'},
3823      body: JSON.stringify({route})
3824    });
3825    await loadRouteHistory();
3826  } catch(e) {}
3827}
3828
3829// ── Ergebnis rendern ──
3830function renderRouteResults(useCoords, stations, stops, totalKm, totalSecs, error) {
3831  document.getElementById('route-form-panel').style.display = 'none';
3832  document.getElementById('route-result-panel').style.display = 'flex';
3833
3834  // Karte initialisieren / leeren
3835  if (!routeMap) {
3836    routeMap = new mapkit.Map('route-map-container', {
3837      showsCompass: mapkit.FeatureVisibility.Hidden,
3838      showsZoomControl: false,
3839      showsUserLocation: true,
3840      colorScheme: pwaMkColorScheme(),
3841    });
3842  } else {
3843    routeMap.removeAnnotations(routeMap.annotations);
3844    routeMap.removeOverlays(routeMap.overlays);
3845  }
3846
3847  // Route als Linie
3848  routeMap.addOverlay(new mapkit.PolylineOverlay(useCoords, {
3849    style: new mapkit.Style({ lineWidth: 4, strokeColor: '#3b82f6' })
3850  }));
3851
3852  // Durchschnittspreis für Farbgebung
3853  const prices = stations.map(s => routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng)).filter(Boolean);
3854  const avgP   = prices.length ? prices.reduce((a,b) => a+b, 0) / prices.length : null;
3855
3856  // Pins: empfohlene Stopps als MarkerAnnotation (groß, nummeriert)
3857  const stopIds = new Set(stops.map(s => s.id));
3858  const stopAnns = [];
3859  stops.forEach((s, i) => {
3860    stopAnns.push(new mapkit.MarkerAnnotation(new mapkit.Coordinate(s.lat, s.lng), {
3861      color: '#e87800',
3862      glyphText: (i + 1).toString(),
3863      title: s.name,
3864      subtitle: (() => { const p = routeMode==='cng'?s.cng:(s.lpg??s.cng); return p?p.toFixed(3)+' '+priceUnit(s)+'/L':''; })(),
3865    }));
3866  });
3867
3868  // Korridor-Stationen als kleine ImageAnnotation (Punkt)
3869  const corrAnns = [];
3870  stations.filter(s => !stopIds.has(s.id)).forEach(s => {
3871    const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng);
3872    const ann = new mapkit.MarkerAnnotation(new mapkit.Coordinate(s.lat, s.lng), {
3873      color: pinColor(price, avgP),
3874      glyphText: '·',
3875      glyphColor: '#fff',
3876      title: s.name,
3877      displayPriority: 250,
3878    });
3879    corrAnns.push(ann);
3880  });
3881
3882  routeMap.addAnnotations([...corrAnns, ...stopAnns]);
3883
3884  // Karte nur auf Route-Linie + Stopps zoomen (nicht auf alle 199 Korridor-Stationen)
3885  const fitItems = stopAnns.length
3886    ? [...stopAnns, ...routeMap.overlays]
3887    : routeMap.overlays;
3888  if (fitItems.length) routeMap.showItems(fitItems, { animate: false, padding: new mapkit.Padding(40,40,40,40) });
3889
3890  // Fehler
3891  const errEl = document.getElementById('route-error-msg');
3892  if (error) { errEl.textContent = '⚠️ ' + error; errEl.style.display = 'block'; }
3893  else errEl.style.display = 'none';
3894
3895  // Info-Zeile
3896  const mins  = Math.round(totalSecs / 60);
3897  const timeStr = mins >= 60 ? `${Math.floor(mins/60)}h ${mins%60}min` : `${mins} min`;
3898  document.getElementById('route-info-bar').innerHTML =
3899    `<span>${Math.round(totalKm)} km · ${timeStr} · ${stations.length} Stationen · ${stops.length} Stopp${stops.length !== 1 ? 's' : ''}</span>`
3900    + (stops.length ? '<button id="btn-route-print" aria-label="Route drucken" title="Route drucken">🖨️</button>' : '');
3901  const printBtn = document.getElementById('btn-route-print');
3902  if (printBtn) printBtn.addEventListener('click', () => printSheet('route-print-sheet'));
3903
3904  // Druckansicht (10.08.26, User-Wunsch: "Auch dort (es gibt ja auch Tablets) eine druckbare
3905  // Liste anbieten"), analog zum Desktop-Routenplaner. Drucker-Symbol statt vollem Button auf
3906  // User-Wunsch rechts neben der Info-Zeile platziert.
3907  const startTxt = document.getElementById('route-start-input').value || 'Start';
3908  const endTxt   = document.getElementById('route-end-input').value   || 'Ziel';
3909  document.getElementById('route-print-sheet').innerHTML = !stops.length ? '' : `
3910    <h1>Route: ${esc(startTxt)} → ${esc(endTxt)}</h1>
3911    <div class="rpp-meta">${Math.round(totalKm)} km · ${timeStr} · ${stops.length} Tankstopp${stops.length !== 1 ? 's' : ''}
3912      · ${routeMode === 'cng' ? 'CNG' : 'LPG'} · gedruckt am ${new Date().toLocaleDateString('de-DE')}</div>
3913    <table>
3914      <thead><tr><th></th><th>Tankstelle</th><th>Adresse</th><th>km</th><th>Preis</th><th>Info</th></tr></thead>
3915      <tbody>
3916        ${stops.map((s, i) => {
3917          const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng);
3918          const addr  = [s.strasse, [s.plz, s.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
3919          const info  = [s.h24 ? '24h' : '', s.d7 ? '7/7' : ''].filter(Boolean).join(' · ');
3920          return `<tr>
3921            <td><span class="rpp-num">${i+1}</span></td>
3922            <td>${esc(s.name)}</td>
3923            <td>${esc(addr)}</td>
3924            <td>${Math.round(s.routeKm)}</td>
3925            <td>${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : '–'}</td>
3926            <td>${esc(info)}</td>
3927          </tr>`;
3928        }).join('')}
3929      </tbody>
3930    </table>`;
3931
3932  // Empfohlene Stopps
3933  const stopsEl = document.getElementById('route-stops');
3934  if (!stops.length && !error) {
3935    stopsEl.innerHTML = '<div class="route-no-stop">✅ Kein Tankstopp nötig — Ziel liegt in Reichweite!</div>';
3936  } else {
3937    stopsEl.innerHTML = stops.map((s, i) => {
3938      const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng);
3939      return `<div class="route-stop-card" data-lat="${s.lat}" data-lng="${s.lng}">
3940        <div class="route-stop-num">${i+1}</div>
3941        <div class="route-stop-body">
3942          <div class="route-stop-name">${esc(s.name)}</div>
3943          ${s.strasse ? `<div class="route-stop-addr">${esc(s.strasse)}</div>` : ''}
3944          <div class="route-stop-addr">${esc(s.plz)} ${esc(s.ort)}</div>
3945          <div class="route-stop-meta">
3946            <span class="km-badge">${Math.round(s.routeKm)} km</span>
3947            <span class="price-tag">${price ? price.toFixed(3)+' '+priceUnit(s)+'/L' : 'kein Preis'}</span>
3948            ${s.h24 ? '<span class="price-tag" style="background:#fef3c7;color:#92400e">24h</span>' : ''}
3949            ${s.d7  ? '<span class="price-tag" style="background:#f0fdf4;color:#166534">7/7</span>' : ''}
3950          </div>
3951        </div>
3952      </div>`;
3953    }).join('');
3954  }
3955
3956  // Klick auf Stop-Card → Karte zoomen
3957  document.querySelectorAll('.route-stop-card[data-lat]').forEach(card => {
3958    card.addEventListener('click', () => {
3959      routeMap.region = new mapkit.CoordinateRegion(
3960        new mapkit.Coordinate(parseFloat(card.dataset.lat), parseFloat(card.dataset.lng)),
3961        new mapkit.CoordinateSpan(0.12, 0.14)
3962      );
3963    });
3964  });
3965
3966  // Alle Stationen
3967  document.getElementById('route-all-list').innerHTML = stations.map(s => {
3968    const price = routeMode === 'cng' ? s.cng : (s.lpg ?? s.cng);
3969    return `<div class="route-station-row" data-lat="${s.lat}" data-lng="${s.lng}" style="cursor:pointer">
3970      <div class="route-station-dot" style="background:${pinColor(price, avgP)}"></div>
3971      <div class="route-station-info">
3972        <div class="route-station-name">${esc(s.name)}</div>
3973        <div class="route-station-addr">${s.strasse ? esc(s.strasse) + ' · ' : ''}${esc(s.plz)} ${esc(s.ort)} · ${Math.round(s.routeKm)} km</div>
3974      </div>
3975      <div style="font-size:.82rem;font-weight:700;color:#333;flex-shrink:0">${price ? price.toFixed(3) : '–'}</div>
3976    </div>`;
3977  }).join('');
3978
3979  // Klick auf Station → Karte zoomen
3980  document.querySelectorAll('.route-station-row[data-lat]').forEach(row => {
3981    row.addEventListener('click', () => {
3982      routeMap.region = new mapkit.CoordinateRegion(
3983        new mapkit.Coordinate(parseFloat(row.dataset.lat), parseFloat(row.dataset.lng)),
3984        new mapkit.CoordinateSpan(0.06, 0.08)
3985      );
3986    });
3987  });
3988
3989  // Toggle "alle Stationen"
3990  document.getElementById('route-all-list').classList.remove('show');
3991  document.getElementById('route-all-toggle').textContent = '▶ Alle Stationen im Korridor';
3992}
3993
3994document.getElementById('route-all-toggle').addEventListener('click', () => {
3995  const el  = document.getElementById('route-all-list');
3996  const tog = document.getElementById('route-all-toggle');
3997  el.classList.toggle('show');
3998  tog.textContent = el.classList.contains('show')
3999    ? '▼ Alle Stationen ausblenden'
4000    : '▶ Alle Stationen im Korridor';
4001});
4002
4003document.getElementById('btn-route-reset').addEventListener('click', () => {
4004  document.getElementById('route-result-panel').style.display = 'none';
4005  document.getElementById('route-form-panel').style.display = '';
4006  document.getElementById('route-all-list').classList.remove('show');
4007});
4008
4009// ── Auth ────────────────────────────────────────────────────────────────────
4010
4011async function checkAuthStatus() {
4012  try {
4013    const r = await fetch('/mobile/api/auth.php?action=status');
4014    const d = await r.json();
4015    currentUser = (d.ok && d.loggedin) ? { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 } : null;
4016  } catch(e) { currentUser = null; }
4017  renderProfileView();
4018}
4019
4020function renderProfileView() {
4021  const loginForm  = document.getElementById('profile-login-form');
4022  const loggedIn   = document.getElementById('profile-loggedin');
4023  const navBtn     = document.querySelector('[data-view="profile"]');
4024  const navLabel   = document.getElementById('nav-profile-label');
4025  const modeBar    = document.getElementById('mode-bar');
4026  // 28.08.2026 (User-Wunsch: "Tankbuch ausgrauen, wenn nicht angemeldet") - renderProfileView()
4027  // laeuft bei jeder currentUser-Aenderung (Login/Logout/OAuth-Callback), daher zentraler Ort
4028  // fuer diesen Zustandsabgleich statt an jeder einzelnen currentUser-Zuweisung zu wiederholen.
4029  const tankbuchBtn = document.querySelector('.nav-btn[data-view="tankbuch"]');
4030  if (tankbuchBtn) tankbuchBtn.classList.toggle('nav-btn-locked', !currentUser);
4031  if (currentUser) {
4032    loginForm.style.display = 'none';
4033    loggedIn.style.display  = '';
4034    const username = currentUser.user;
4035    const realname = currentUser.vorname;
4036    document.getElementById('profile-name').textContent   = username + (currentUser.is_admin ? ' [' + currentUser.is_admin + ']' : '');
4037    const realnameEl = document.getElementById('profile-realname');
4038    if (realnameEl) {
4039      realnameEl.textContent = (realname && realname !== username) ? realname : '';
4040      realnameEl.style.display = realnameEl.textContent ? '' : 'none';
4041    }
4042    document.getElementById('profile-avatar').textContent = username.charAt(0).toUpperCase();
4043    if (navLabel) navLabel.textContent = username.substring(0, 6) + '…';
4044    if (navBtn) navBtn.classList.add('is-logged-in');
4045    // Gas-Typ aus Profil übernehmen und Mode-Bar ausblenden
4046    const lockedGas = currentUser.gas || localStorage.getItem('gasdb_gas_locked');
4047    if (lockedGas) {
4048      if (currentUser.gas) setMode(currentUser.gas);
4049      if (modeBar) modeBar.style.display = 'none';
4050      currentUser.gas = currentUser.gas || lockedGas;
4051      // Routenplaner-Gasart setzen und Auswahl ausblenden
4052      routeMode = currentUser.gas;
4053      localStorage.setItem('routeMode', routeMode);
4054      document.querySelectorAll('.route-mode-btn').forEach(b =>
4055        b.classList.toggle('active', b.dataset.rmode === routeMode));
4056      const routeModeRow = document.querySelector('.route-mode-row');
4057      if (routeModeRow) routeModeRow.style.display = 'none';
4058      document.querySelectorAll('.gas-pick-btn').forEach(b =>
4059        b.classList.toggle('active', b.dataset.gas === lockedGas));
4060    }
4061    // Favoriten-Tabs einblenden
4062    const favTabs = document.getElementById('fav-tabs');
4063    if (favTabs) favTabs.style.display = '';
4064    loadRouteHistory();
4065    // Admins: Spenden-Karte durch Kennzahlen-Karte ersetzen
4066    const donateCard = document.getElementById('profile-donate-card');
4067    const adminCard  = document.getElementById('profile-admin-card');
4068    if (currentUser.is_admin) {
4069      if (donateCard) donateCard.style.display = 'none';
4070      if (adminCard)  adminCard.style.display  = '';
4071      loadAdminStats();
4072    } else {
4073      if (donateCard) donateCard.style.display = '';
4074      if (adminCard)  adminCard.style.display  = 'none';
4075    }
4076  } else {
4077    loginForm.style.display = '';
4078    loggedIn.style.display  = 'none';
4079    if (navLabel) navLabel.textContent = 'Profil';
4080    if (navBtn) navBtn.classList.remove('is-logged-in');
4081    // Mode-Bar und Route-Gasauswahl wieder anzeigen (außer Route-View)
4082    if (modeBar && activeView !== 'route') modeBar.style.display = '';
4083    const routeModeRow = document.querySelector('.route-mode-row');
4084    if (routeModeRow) routeModeRow.style.display = '';
4085    // Tabs ausblenden, auf Lokal zurücksetzen
4086    const favTabs = document.getElementById('fav-tabs');
4087    if (favTabs) favTabs.style.display = 'none';
4088    if (activeFavTab === 'profile') {
4089      activeFavTab = 'local';
4090      document.querySelectorAll('.fav-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'local'));
4091    }
4092    profileFavCache = null;
4093    renderFavoritesHome();
4094  }
4095}
4096
4097async function loadAdminStats() {
4098  try {
4099    const r = await fetch('/mobile/api/admin_stats.php');
4100    const d = await r.json();
4101    if (!d.ok) return;
4102    document.getElementById('stat-pageviews').textContent   = d.pageviews_24h;
4103    document.getElementById('stat-apicalls').textContent    = d.api_calls_24h;
4104    document.getElementById('stat-activeusers').textContent = d.aktive_user_30d;
4105    document.getElementById('stat-loginpct').textContent    = d.login_user_pct + '%';
4106  } catch(e) { /* Admin-Kennzahlen optional, still bei Fehler */ }
4107}
4108
4109let profileOtpPending = false;
4110
4111document.getElementById('btn-do-login').addEventListener('click', async () => {
4112  const username = document.getElementById('profile-username').value.trim();
4113  const password = document.getElementById('profile-password').value;
4114  const otpField = document.getElementById('profile-otp-field');
4115  const otpCode  = document.getElementById('profile-otp-code').value.trim();
4116  const errEl    = document.getElementById('profile-error');
4117  errEl.style.display = 'none';
4118  if (!username || !password) {
4119    errEl.textContent = 'Bitte Benutzername und Passwort eingeben';
4120    errEl.style.display = 'block';
4121    return;
4122  }
4123  if (profileOtpPending && !otpCode) {
4124    errEl.textContent = 'Bitte 2FA-Code eingeben';
4125    errEl.style.display = 'block';
4126    return;
4127  }
4128  const btn = document.getElementById('btn-do-login');
4129  btn.disabled = true; btn.textContent = 'Anmelden…';
4130  try {
4131    const fd = new FormData();
4132    fd.append('action', 'login'); fd.append('username', username); fd.append('password', password);
4133    if (profileOtpPending) fd.append('otp_code', otpCode);
4134    const r = await fetch('/mobile/api/auth.php', { method: 'POST', body: fd });
4135    const d = await r.json();
4136    if (d.ok) {
4137      currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 };
4138      document.getElementById('profile-password').value = '';
4139      document.getElementById('profile-otp-code').value = '';
4140      profileOtpPending = false;
4141      otpField.style.display = 'none';
4142      renderProfileView();
4143      showToast('Willkommen, ' + (d.vorname || d.user) + '!');
4144    } else if (d.need_2fa) {
4145      profileOtpPending = true;
4146      otpField.style.display = '';
4147      document.getElementById('profile-otp-code').focus();
4148      errEl.textContent = d.error || 'Bitte 2FA-Code eingeben';
4149      errEl.style.display = 'block';
4150    } else {
4151      errEl.textContent = d.error || 'Anmeldung fehlgeschlagen';
4152      // 20.08.2026 Bugfix (der wahre Kern-Bug hinter "keine Fehlermeldung" beim knufloti-/
4153      // Njumaen-Login-Debugging!): errEl.style.display='' entfernt nur einen etwaigen Inline-
4154      // Style, ueberschreibt aber NICHT die CSS-Klassenregel ".p-error { display: none; }" -
4155      // computed display blieb "none", die Fehlermeldung stand zwar korrekt im DOM (textContent),
4156      // war aber nie sichtbar. Live auf Prod im Browser reproduziert (Netzwerk-Antwort enthielt
4157      // den korrekten Fehlertext, sichtbar war nichts). 'block' ueberschreibt die Klassenregel
4158      // korrekt per Inline-Style.
4159      errEl.style.display = 'block';
4160    }
4161  } catch(e) {
4162    errEl.textContent = 'Verbindungsfehler';
4163    errEl.style.display = 'block';
4164  }
4165  btn.disabled = false; btn.textContent = 'Anmelden';
4166});
4167
4168document.getElementById('btn-do-logout').addEventListener('click', async () => {
4169  const fd = new FormData(); fd.append('action', 'logout');
4170  await fetch('/mobile/api/auth.php', { method: 'POST', body: fd });
4171  currentUser = null;
4172  renderProfileView();
4173  showToast('Abgemeldet');
4174});
4175
4176// Eingabe mit Enter bestätigen
4177document.getElementById('profile-password').addEventListener('keydown', e => {
4178  if (e.key === 'Enter') document.getElementById('btn-do-login').click();
4179});
4180document.getElementById('profile-otp-code').addEventListener('keydown', e => {
4181  if (e.key === 'Enter') document.getElementById('btn-do-login').click();
4182});
4183// Passwort nachtraeglich geaendert -> pending 2FA-Status verwerfen (sonst wuerde ein alter Code
4184// gegen einen neuen Login-Versuch mitgeschickt)
4185document.getElementById('profile-password').addEventListener('input', () => {
4186  if (profileOtpPending) {
4187    profileOtpPending = false;
4188    document.getElementById('profile-otp-field').style.display = 'none';
4189    document.getElementById('profile-otp-code').value = '';
4190  }
4191});
4192
4193// ── OAuth: gemeinsamer Handler nach erfolgreichem Login ──────────────────────
4194function handleOAuthSuccess(d) {
4195  currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 };
4196  document.getElementById('oauth-link-box').style.display = 'none';
4197  renderProfileView();
4198  showToast('Willkommen, ' + (d.vorname || d.user) + '!');
4199  // Gas-Typ aus erster Suche: bereits gesetzt? → Mode-Bar ausblenden
4200  if (d.gas && !currentUser.gas) autoSaveGas(d.gas);
4201}
4202
4203async function oauthPost(body) {
4204  const fd = new FormData();
4205  for (const [k, v] of Object.entries(body)) fd.append(k, v);
4206  const r = await fetch('/mobile/api/oauth.php', { method: 'POST', body: fd });
4207  return await r.json();
4208}
4209
4210// Verknüpfungsdialog: Sub + Provider zwischenspeichern
4211let oauthPending = null;
4212
4213function showLinkBox(d) {
4214  oauthPending = { provider: d.provider, sub: d.sub, email: d.email };
4215  document.getElementById('oauth-link-msg').textContent =
4216    `E-Mail „${d.email}" ist bereits registriert. Passwort eingeben um Konten zu verknüpfen.`;
4217  document.getElementById('oauth-link-pw').value = '';
4218  document.getElementById('oauth-link-error').textContent = '';
4219  document.getElementById('oauth-link-box').style.display = '';
4220  document.getElementById('oauth-link-pw').focus();
4221}
4222
4223document.getElementById('btn-oauth-link').addEventListener('click', async () => {
4224  if (!oauthPending) return;
4225  const pw = document.getElementById('oauth-link-pw').value;
4226  const errEl = document.getElementById('oauth-link-error');
4227  if (!pw) { errEl.textContent = 'Passwort eingeben'; return; }
4228  const btn = document.getElementById('btn-oauth-link');
4229  btn.disabled = true; btn.textContent = 'Prüfe…';
4230  try {
4231    const d = await oauthPost({ action: 'link', ...oauthPending, password: pw });
4232    if (d.ok) { oauthPending = null; handleOAuthSuccess(d); }
4233    else { errEl.textContent = d.error || 'Fehler'; }
4234  } catch(e) { errEl.textContent = 'Verbindungsfehler'; }
4235  btn.disabled = false; btn.textContent = 'Verknüpfen & anmelden';
4236});
4237
4238document.getElementById('btn-oauth-link-cancel').addEventListener('click', () => {
4239  oauthPending = null;
4240  document.getElementById('oauth-link-box').style.display = 'none';
4241});
4242
4243// ── Sign in with Apple ────────────────────────────────────────────────────────
4244document.getElementById('btn-apple-signin').addEventListener('click', async () => {
4245  if (!APPLE_SERVICE_ID) { showToast('Apple Sign In nicht konfiguriert'); return; }
4246  try {
4247    const resp = await AppleID.auth.signIn();
4248    const token = resp?.authorization?.id_token;
4249    if (!token) { showToast('Apple-Anmeldung abgebrochen'); return; }
4250    const first = resp?.user?.name?.firstName || '';
4251    const last  = resp?.user?.name?.lastName  || '';
4252    const d = await oauthPost({ action: 'apple', identity_token: token, first_name: first, last_name: last });
4253    if (d.ok) { handleOAuthSuccess(d); }
4254    else if (d.action === 'link_required') { showLinkBox(d); }
4255    else { showToast(d.error || 'Apple-Anmeldung fehlgeschlagen'); }
4256  } catch(e) {
4257    const code = e?.error || (typeof e === 'string' ? e : '');
4258    if (code === 'popup_closed_by_user') return;
4259    // Häufige Fehler-Codes von Apple erklären
4260    const msgs = {
4261      'invalid_client':        'Service ID ungültig – Developer Portal prüfen',
4262      'invalid_request':       'Return URL nicht registriert – Developer Portal prüfen',
4263      'invalid_grant':         'Apple-Token abgelaufen – erneut versuchen',
4264      'user_cancelled_authorize': 'Abgebrochen',
4265    };
4266    showToast(msgs[code] || ('Apple-Fehler: ' + (code || JSON.stringify(e))));
4267    console.error('Apple Sign In error:', e);
4268  }
4269});
4270
4271// ── Sign in with Google (Redirect-Flow, mobilfreundlich) ─────────────────────
4272const btnGoogle = document.getElementById('btn-google-signin');
4273if (btnGoogle) {
4274  btnGoogle.addEventListener('click', () => {
4275    if (!GOOGLE_CLIENT_ID) { showToast('Google Sign In nicht konfiguriert'); return; }
4276    window.location.href = '/login_google_start.php?pwa=1';
4277  });
4278}
4279
4280// Gas-Typ automatisch speichern (einmalig beim ersten OAuth-Login ohne gas)
4281// ── Profil: Gas-Typ wählen ───────────────────────────────────────────────────
4282// 21.08.2026 Bugfix (User-Report: "Umschalter der Darstellung funktioniert, zeigt aber immer
4283// kurz 'Falsche Gasart' an") - die Theme-Buttons (Hell/Dunkel/Auto) teilen sich aus Styling-
4284// Gruenden dieselbe CSS-Klasse .gas-pick-btn mit den LPG/CNG-Buttons, wurden dadurch aber auch
4285// von DIESEM Handler erfasst und loesten bei jedem Klick zusaetzlich ein sinnloses set_gas-
4286// Request ohne data-gas aus -> Server lehnt mit "Ungueltige Gasart" ab (oauth.php), kurz als
4287// Toast sichtbar. Der eigentliche Theme-Wechsel lief parallel ueber den separaten
4288// [data-theme-choice]-Handler korrekt durch. Selektor auf [data-gas] eingegrenzt, damit nur die
4289// echten Gas-Typ-Buttons erfasst werden.
4290document.querySelectorAll('.gas-pick-btn[data-gas]').forEach(btn => {
4291  btn.addEventListener('click', async () => {
4292    if (!currentUser) return;
4293    const gas = btn.dataset.gas;
4294    btn.disabled = true;
4295    try {
4296      const d = await oauthPost({ action: 'set_gas', gas });
4297      if (d.ok) {
4298        currentUser.gas = gas;
4299        setMode(gas);
4300        localStorage.setItem('gasdb_gas_locked', gas);
4301        renderProfileView();
4302        showToast((gas === 'lpg' ? '⛽ LPG' : '🔵 CNG') + ' als Gas-Typ gespeichert');
4303      } else { showToast(d.error || 'Fehler beim Speichern'); }
4304    } catch(e) { showToast('Verbindungsfehler'); }
4305    btn.disabled = false;
4306  });
4307});
4308
4309async function autoSaveGas(gas) {
4310  if (!gas || !currentUser) return;
4311  await oauthPost({ action: 'set_gas', gas });
4312  currentUser.gas = gas;
4313  renderProfileView();
4314}
4315
4316// ── Preismeldungs-Sheet ──────────────────────────────────────────────────────
4317let priceSheetStation = null;
4318
4319function openPriceSheet(s) {
4320  priceSheetStation = s;
4321  const gas   = currentMode === 'cng' ? 'cng' : 'lpg';
4322  const price = gas === 'cng' ? s.cng : s.lpg;
4323  const stand = gas === 'cng' ? s.cng_stand : s.lpg_stand;
4324
4325  document.getElementById('ps-btn-confirm').textContent =
4326    `✓ ${price ? price.toFixed(3) + ' ' + priceUnit(s) : ''} bestätigen`;
4327  document.getElementById('ps-btn-confirm').disabled = !price;
4328
4329  // 04.09.2026: Preiszeile ist jetzt immer offen (kein "Preis aktualisieren"-Umschalt-Button
4330  // mehr, siehe HTML-Kommentar), nur die Nachrichten-Zeile wird beim (Wieder-)Öffnen geschlossen.
4331  document.getElementById('ps-msg-row').classList.remove('open');
4332  document.getElementById('ps-price-input').value = price ? price.toFixed(3) : '';
4333
4334  // 04.09.2026 (User-Wunsch: "aktualisieren" bzw. "melden" in Abhängigkeit der EFFEKTIVEN
4335  // Sperre anzeigen, nicht generisch immer "Melden") - Submit-Button startet neutral, wird nach
4336  // der asynchronen Status-Abfrage (Admin/Selbstmelder/Premium-Versorger/-Betreiber dieser
4337  // Station umgehen die Sperre, siehe price_report.php action=status) korrekt beschriftet.
4338  const submitBtn = document.getElementById('ps-price-submit');
4339  submitBtn.textContent = '…';
4340  fetch('api/price_report.php', {
4341    method: 'POST',
4342    body: new URLSearchParams({ action: 'status', tankid: s.id, gas })
4343  })
4344    .then(r => r.json())
4345    .then(d => {
4346      if (priceSheetStation !== s) return; // Sheet inzwischen geschlossen/gewechselt
4347      const locked = d.ok && d.gesperrt_fuer_mich;
4348      submitBtn.textContent = locked ? 'Melden' : 'Aktualisieren';
4349      // 29.09.2026: Bestaetigen unterliegt jetzt derselben Sperre wie Aktualisieren (Server-Gate in
4350      // price_report.php/preis_gesperrt_fuer()) - Button gleich deaktivieren statt erst abzulehnen.
4351      if (locked) {
4352        const cb = document.getElementById('ps-btn-confirm');
4353        cb.disabled = true;
4354        cb.textContent = 'Bestätigen gesperrt';
4355      }
4356    })
4357    .catch(() => { if (priceSheetStation === s) submitBtn.textContent = 'Melden'; });
4358
4359  document.getElementById('price-sheet-overlay').classList.add('open');
4360}
4361
4362function closePriceSheet() {
4363  document.getElementById('price-sheet-overlay').classList.remove('open');
4364  priceSheetStation = null;
4365}
4366
4367async function priceReport(action, extra = {}) {
4368  if (!priceSheetStation) return;
4369  // 04.09.2026 Bugfix: Station VOR closePriceSheet() lokal sichern - closePriceSheet() setzt
4370  // priceSheetStation auf null, der Erfolgs-Zweig unten griff bisher trotzdem noch darauf zu
4371  // (TypeError, unbehandelt in dieser async function - Toast + Detail-Neuzeichnen blieben nach
4372  // einem erfolgreichen Update/Bestaetigen stumm aus, nur in der Konsole sichtbar).
4373  const station = priceSheetStation;
4374  const gas = currentMode === 'cng' ? 'cng' : 'lpg';
4375  const newPrice = extra.price ? parseFloat(String(extra.price).replace(',', '.')) : null;
4376  const body = new URLSearchParams({ action, tankid: station.id, gas, ...extra });
4377  let data;
4378  try {
4379    const r = await fetch('api/price_report.php', { method: 'POST', body });
4380    data = await r.json();
4381  } catch(e) {
4382    closePriceSheet();
4383    showToast('Fehler beim Senden', undefined, 'error');
4384    return;
4385  }
4386  closePriceSheet();
4387  if (data.ok) {
4388    // Optimistisches Update: Preis + Timestamp im lokalen Objekt sofort setzen
4389    if (action === 'update' && !data.queued && newPrice) {
4390      const standKey = gas === 'cng' ? 'cng_stand' : 'lpg_stand';
4391      station[gas] = newPrice;
4392      station[standKey] = Math.floor(Date.now() / 1000);
4393      // Station auch in currentStations aktualisieren
4394      const idx = currentStations.findIndex(x => x.id === station.id);
4395      if (idx !== -1) {
4396        currentStations[idx][gas] = newPrice;
4397        currentStations[idx][standKey] = station[standKey];
4398      }
4399      // Detail-View neu rendern
4400      showDetail(station);
4401    } else if (action === 'confirm') {
4402      const standKey = gas === 'cng' ? 'cng_stand' : 'lpg_stand';
4403      station[standKey] = Math.floor(Date.now() / 1000);
4404      showDetail(station);
4405    }
4406  }
4407  // 05.09.2026 (User-Idee: "nach dem Erfolg und einem 'Danke!' wieder das grüne mit dem Button
4408  // (ohne Hilfe) zeigen, dann kann daraus ein neues Update gestartet werden") - im Quick-Jump-
4409  // Kontext (?q, siehe _quickJump weiter unten im selben Script) ersetzt nach jeder erfolgreichen
4410  // Aktion ein kurzes "Danke!" + das gruene Wiederholungs-Overlay den normalen Toast, damit direkt
4411  // die naechste Tankstelle drankommt - ein "Fenster schliessen" ist auf iOS ohnehin technisch
4412  // nicht moeglich (window.close() wirkt nur bei per Skript selbst geoeffneten Fenstern).
4413  if (_quickJump && data.ok) {
4414    showToast('Danke!', 1200, 'success');
4415    setTimeout(showQuickJumpAgain, 1400);
4416    return;
4417  }
4418  showToast(data.msg || (data.error ? `Fehler: ${data.error}` : 'Fehler'), undefined, data.ok ? 'success' : 'error');
4419}
4420
4421// Siehe Kommentar in priceReport() oben - zeigt das gruene q.php-Overlay (ohne Anleitung), ein
4422// Tap startet quickJumpToNearest() erneut fuer die naechste Tankstelle.
4423function showQuickJumpAgain() {
4424  document.getElementById('quickjump-again-overlay').hidden = false;
4425}
4426document.getElementById('qj-again-btn').addEventListener('click', () => {
4427  document.getElementById('quickjump-again-overlay').hidden = true;
4428  quickJumpToNearest();
4429});
4430
4431document.getElementById('ps-btn-confirm').addEventListener('click', () => priceReport('confirm'));
4432
4433// 04.09.2026 (User-Idee: "rechts daneben ein Quadrat gleicher Höhe mit Check-Haken als schnelle
4434// Preis-Bestätigung"): bestätigt den aktuellen Preis mit einem Tap, ohne das Preismeldungs-Sheet
4435// zu öffnen - nutzt dieselbe priceReport('confirm')-Logik wie der Sheet-Button.
4436function quickConfirmPrice(s) {
4437  priceSheetStation = s;
4438  priceReport('confirm');
4439}
4440
4441// 04.09.2026 (User-Wunsch: "die Preiseingabe per Eingabe-Feld ist so wie es ist unterwegs nicht
4442// gut gemacht" - ein nacktes Zahlenfeld+Tastatur ist mit einer Hand/Handschuhen am Automaten
4443// unhandlich): +/- Stepper mit grossen Tap-Zielen, Long-Press beschleunigt automatisch.
4444(function initPriceStepper() {
4445  const PS_STEP = 0.01;
4446  const input = document.getElementById('ps-price-input');
4447
4448  function adjust(delta) {
4449    const cur = parseFloat(String(input.value).replace(',', '.')) || 0;
4450    const next = Math.max(0, Math.round((cur + delta) * 1000) / 1000);
4451    input.value = next.toFixed(3);
4452  }
4453
4454  function bindStepper(btn, delta) {
4455    let delay = null, accel = null;
4456    const stop = () => { clearTimeout(delay); clearInterval(accel); delay = null; accel = null; };
4457    btn.addEventListener('pointerdown', e => {
4458      e.preventDefault();
4459      adjust(delta);
4460      delay = setTimeout(() => { accel = setInterval(() => adjust(delta), 90); }, 450);
4461    });
4462    ['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => btn.addEventListener(ev, stop));
4463  }
4464
4465  bindStepper(document.getElementById('ps-step-minus'), -PS_STEP);
4466  bindStepper(document.getElementById('ps-step-plus'), PS_STEP);
4467})();
4468
4469document.getElementById('ps-price-submit').addEventListener('click', () => {
4470  const val = document.getElementById('ps-price-input').value.trim();
4471  if (!val) return;
4472  priceReport('update', { price: val });
4473});
4474
4475document.getElementById('ps-btn-msg').addEventListener('click', () => {
4476  const row = document.getElementById('ps-msg-row');
4477  const open = row.classList.toggle('open');
4478  // 04.09.2026: Preiszeile blendet sich nur waehrend der Nachrichtenerfassung aus (Platzgrund im
4479  // Sheet) und kommt beim Schliessen der Nachrichtenzeile automatisch wieder - vorher blieb sie
4480  // nach einem Toggle dauerhaft ausgeblendet.
4481  document.getElementById('ps-price-input-row').classList.toggle('open', !open);
4482  if (open) document.getElementById('ps-msg-input').focus();
4483});
4484
4485document.getElementById('ps-msg-submit').addEventListener('click', () => {
4486  const val = document.getElementById('ps-msg-input').value.trim();
4487  if (!val) return;
4488  priceReport('message', { message: val });
4489});
4490
4491document.getElementById('ps-btn-cancel').addEventListener('click', closePriceSheet);
4492document.getElementById('price-sheet-overlay').addEventListener('click', e => {
4493  if (e.target === document.getElementById('price-sheet-overlay')) closePriceSheet();
4494});
4495
4496// ── Start ──
4497renderFavoritesHome();
4498
4499// 05.09.2026 (User-Idee: "vereinfachte PWA-URL um sofort auf die Detail-Ansicht der nächsten
4500// Tankstelle zu gelangen [...] fahre ich an einer Tankstelle vorbei und will nur kurz den
4501// angezeigten Preis aktualisieren", verkürzt auf "/mobile?q" statt Hash/eigenem Rewrite) - Flag
4502// VOR dem untenstehenden history.replaceState() sichern, das den Query-String synchron entfernt,
4503// bevor der finale showView('home')-Guard weiter unten ausgewertet wird.
4504// 05.09.2026: "let" statt "const" - der Bottom-Nav-Klick-Handler oben setzt das Flag beim
4505// bewussten Verlassen des Quick-Jump-Loops zurueck auf false (siehe dortiger Kommentar).
4506let _quickJump = new URLSearchParams(window.location.search).has('q');
4507
4508// Apple Redirect-Flow: Hash auswerten (#oauth_token / #oauth_link / #oauth_error)
4509(async () => {
4510  const hash = window.location.hash;
4511  if (hash.startsWith('#oauth_token=')) {
4512    // Token einlösen → Session auf diesem Server setzen
4513    const params  = new URLSearchParams(hash.slice(1));
4514    const token   = params.get('oauth_token') || '';
4515    const name    = decodeURIComponent(params.get('name') || '');
4516    history.replaceState(null, '', '/mobile/');
4517    if (token) {
4518      try {
4519        const fd = new FormData();
4520        fd.append('action', 'redeem_token'); fd.append('token', token);
4521        const r = await fetch('/mobile/api/oauth.php', { method: 'POST', body: fd });
4522        const d = await r.json();
4523        if (d.ok) {
4524          currentUser = { user: d.user, vorname: d.vorname, userindex: d.userindex, gas: d.gas, is_admin: d.is_admin || 0 };
4525          renderProfileView();
4526          showToast('Willkommen, ' + (d.vorname || d.user || name) + '!');
4527        } else { showToast('Anmeldung fehlgeschlagen: ' + (d.error || '')); }
4528      } catch(e) { showToast('Verbindungsfehler beim Token-Einlösen'); }
4529    }
4530  } else if (hash.startsWith('#oauth_link=')) {
4531    await checkAuthStatus();
4532    try {
4533      const data = JSON.parse(decodeURIComponent(hash.slice(12)));
4534      showLinkBox({ provider: data.provider, sub: data.sub, email: data.email, action: 'link_required' });
4535      showView('profile');
4536    } catch(e) {}
4537    history.replaceState(null, '', '/mobile/');
4538  } else if (hash.startsWith('#oauth_error=')) {
4539    const code = hash.slice(13);
4540    await checkAuthStatus();
4541    showToast('Apple-Fehler: ' + code);
4542    history.replaceState(null, '', '/mobile/');
4543  } else if (_quickJump) {
4544    // /mobile?q - kuerzeste Variante, keine eigene Rewrite-Regel noetig: Apache leitet "/mobile"
4545    // ohnehin auf "/mobile/" um und behaelt dabei den Query-String bei ("/mobile/?q").
4546    // 05.09.2026 Nachbesserung (User: Safari "verliert" das "?q", dadurch liess sich die Seite
4547    // nicht mit dem Kurz-Link zum Home-Bildschirm hinzufuegen) - KEIN history.replaceState() hier
4548    // (anders als bei den OAuth-Zweigen oben, wo ein sensibler Token aus der Adresszeile entfernt
4549    // werden MUSS): "Zum Home-Bildschirm hinzufuegen" uebernimmt die aktuell in der Adressleiste
4550    // stehende URL - ein sofortiges Saeubern haette das "?q" schon vor dem manuellen Hinzufuegen
4551    // wieder entfernt.
4552    await checkAuthStatus();
4553    quickJumpToNearest();
4554  } else {
4555    await checkAuthStatus();
4556  }
4557})();
4558
4559// ?q wechselt selbst in die Detailansicht, sobald der Standort vorliegt - kein Zwischenblitz auf
4560// 'home', der Loader bleibt bis dahin sichtbar (quickJumpToNearest() steuert ihn selbst).
4561if (!_quickJump) {
4562  showView('home');
4563  showLoader(false);
4564}
4565</script>
4565
4566</body>
4567</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.