/* ================================================================ Audi-Dashboard — panel_custom Custom Element ================================================================ Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant. Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den Entitäten der Integration geladen statt fest im Code zu stehen) und die DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten). Bekannte Lücken in diesem Stand: - Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als gebrochene Bilder, bis bilder/ tatsächlich befüllt ist. - Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur, wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat. */ /* --------------------------------------------------------------------- Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`) weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel- Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein existierte. --------------------------------------------------------------------- */ let HASS = null; let ROOT = null; // this.shadowRoot der Panel-Instanz let HOST = null; // die Panel-Instanz selbst (für :host([data-theme])) let CONFIG = null; let CAR = null; let TRIPS = []; let FILLS = []; let BATTERIEVERLAUF = []; let ENTITAETEN = null; // {katalog, werte} aus sensor.audi_dashboard_entitaeten (Setup-Menü) let DATEN_GELADEN = false; // Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - // null, solange keiner bekannt ist (siehe standAlterText()). let LETZTES_UPDATE_TS = null; /* Cache-Brecher für audi-dashboard.css und audi-dashboard-ios.css: die Stylesheets werden nicht als Modul geladen, sondern als in den Shadow-Root gehängt - sie brauchen deshalb ihre eigene Versions-Query. Ohne sie bliebe ein Browser, der sie einmal geladen hat, nach einer CSS-Änderung auf dem alten Stand hängen, obwohl der Code selbst korrekt neu geladen wird. Die Zahl steht schon in der eigenen Modul-URL (?v=…, von audi-dashboard-panel.js durchgereicht, ursprünglich von der Integration beim Anmelden des Panels gesetzt) - eine eigene Abfrage braucht es nicht. */ const CSS_VERSION = (() => { try { return new URL(import.meta.url).searchParams.get("v") || Date.now(); } catch (e) { return Date.now(); } })(); /* Basis-URL der ausgelieferten Dateien der Integration (CSS, Badges, Markenlogo). Aus der eigenen Modul-URL abgeleitet statt fest verdrahtet: damit stimmt sie auch dann, wenn sich der statische Pfad in const.py einmal ändert, und es gibt keine zweite Stelle, die mitgepflegt werden müsste. NICHT hierunter fallen die Fahrzeugfotos: die lädt der Nutzer selbst hoch und sie liegen weiterhin unter /local/bilder/ (siehe bilder.py) - im Integrationsordner würde sie das nächste Update mitlöschen. */ const STATIK = new URL(".", import.meta.url).href.replace(/\/$/, ""); /* Schriftschnitte MUESSEN im Dokument registriert werden, nicht im Shadow Root: @font-face-Regeln innerhalb eines Schattenbaums ignoriert der Browser grundsaetzlich, Schriftfamilien sind dokumentweit. Weil audi-dashboard.css (mit den eingebetteten Schnitten am Anfang) hier immer nur an ROOT gehaengt wurde, ist das Panel seit jeher auf Helvetica zurueckgefallen, obwohl "Audi Type" ganz vorn in jedem font-family-Stapel steht - beim Paritaetsabgleich 2026-08-30 dadurch aufgefallen, dass dieselbe Zeichenkette im Panel exakt so breit rendert wie in Helvetica, waehrend die companion-app mit derselben, bytegleichen Schriftdatei ~5 % schmaler setzt. Die Schnitte liegen deshalb jetzt in audi-dashboard-schriften.css, und nur diese Datei geht in document.head - sie enthaelt ausschliesslich @font-face und beeinflusst die uebrige HA-Oberflaeche nicht. Der Umkehrfall ist in diesem Projekt schon einmal passiert: das Leaflet-Stylesheet lag in document.head und erreichte den Shadow Root nicht (siehe AGENTS.md, "fragmentierte Leaflet-Darstellung"). Beide Faelle haben dieselbe Ursache - Stylesheets wirken je Baum, Schriften nur im Dokument. */ const SCHRIFTEN_ID = "audi-dashboard-schriften"; function schriftenLaden() { if (document.getElementById(SCHRIFTEN_ID)) return; const link = document.createElement("link"); link.id = SCHRIFTEN_ID; link.rel = "stylesheet"; link.href = `${STATIK}/audi-dashboard-schriften.css?v=${CSS_VERSION}`; document.head.appendChild(link); } /* ------------------------------------------------- Namen des Backends Die eine Stelle, an der die Entity-IDs und Dienstnamen stehen. Sie sind ein Vertrag mit custom_components/audi_dashboard/const.py; die Companion-App führt dieselbe Tabelle in src/api/types.ts. Bis 2026-08-23 hießen sie pyscript.audi_dashboard_* bzw. pyscript.audi_dashboard_: der Präfix gehörte pyscript, das die Entitäten damals bereitstellte. Seit dem Umbau zur eigenen Integration gehören sie ihr auch dem Namen nach. */ const DOMAIN = "audi_dashboard"; const E = { profil: "sensor.audi_dashboard_profil", fahrten: "sensor.audi_dashboard_fahrten", tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge", fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus", batterieverlauf: "sensor.audi_dashboard_batterieverlauf", entitaeten: "sensor.audi_dashboard_entitaeten", belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis", importStatus: "sensor.audi_dashboard_import_status", appVersion: "sensor.audi_dashboard_app_version", }; const $ = (s) => ROOT.querySelector(s); /* ---------------------------------------------------------------- Format */ const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); /* Eine gefahrene Strecke in der Staffelung, die der Eigentuemer am 01.09.2026 festgelegt hat: unter 1 km -> Meter, ganzzahlig ("400 m", "999 m") bis 99,9 km -> eine Nachkommastelle ("1,0 km", "6,9 km") ab 100 km -> ganze Kilometer ("104 km") Der Grund fuer die Staffelung: seit der GNSS-Verfeinerung messen wir auf etwa zehn Meter genau (Fahrt vom 01.09.: 5,015 km GNSS gegen 5,000 km am Tacho, 0,3 %). Ohne Nachkommastelle wurde davon nichts sichtbar - aus 6,896 km wurde in der Anzeige schlicht "7 km". Umgekehrt waere eine Nachkommastelle bei dreistelligen Summen nur Unruhe. Gestaffelt wird nach dem GERUNDETEN Wert, nicht nach dem rohen: 0,9996 km sind gerundet 1000 m und gehoeren damit schon in die km-Stufe, sonst stuende dort "1.000 m". Dasselbe an der Grenze zu 100. */ function streckeTeile(km) { const n = Number(km); if (km == null || !Number.isFinite(n)) return null; const meter = Math.round(n * 1000); if (Math.abs(meter) < 1000) return { wert: de(meter), einheit: "m" }; const fein = Math.round(n * 10) / 10; if (Math.abs(fein) < 100) return { wert: de(fein, 1), einheit: "km" }; return { wert: de(Math.round(n)), einheit: "km" }; } // Der Name streckeText, nicht strecke: strecke(t) gibt es bereits fuer die // Ortsangaben einer Fahrt ("Ingolstadt -> Muenchen"). const streckeText = (km, ersatz = "–") => { const t = streckeTeile(km); return t ? `${t.wert} ${t.einheit}` : ersatz; }; /* Preis je Liter, oder null. Die Kosten muessen ausdruecklich geprueft werden, nicht nur die Liter: bei einem automatisch erkannten Tankvorgang steht der Preis noch nicht fest, und `null / 11.5` ist in JavaScript `0`. Die Liste, der Einzelbeleg und die CSV-Ausgabe zeigten daraufhin "0,00 €/l" - einen Preis, den niemand bezahlt hat. Die App prueft an derselben Stelle seit jeher auf beides. */ const literpreis = (f) => (f && f.liters && f.fuel_total_eur != null ? f.fuel_total_eur / f.liters : null); const eur = (n) => de(n, 2); const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const sum = (a, f) => a.reduce((s, x) => s + f(x), 0); /* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */ /* Ohne die Nennerpruefung liefert ein Zeitraum, in dem kein Tankvorgang Liter traegt, 0/0 = NaN - und einer mit Kosten aber ohne Liter eine Division durch null. de() reicht beides ungefiltert durch, die Kopfzeile las dann "Ø NaN €/l" bzw. "Ø ∞ €/l". Einzelne solche Datensaetze gibt es real; es braucht nur einen Monat, der ausschliesslich daraus besteht. Die App hat diesen Schutz samt Test seit jeher (format.ts). */ const schnittpreis = (list) => { const liter = sum(list, (f) => f.liters ?? f.liter ?? 0); if (liter <= 0) return 0; return sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / liter; }; /* -------------------------------------------------------- Adapter-Schicht Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/ einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus dem Prototyp übernommen, um dessen Render-Funktionen unverändert weiterzuverwenden. */ function profilZuConfig(p) { return { fahrzeugtitel: p.fahrzeug.titel, fahrzeugzusatz: p.fahrzeug.zusatz, fahrzeuguntertitel: p.fahrzeug.untertitel, kennzeichen: p.fahrzeug.kennzeichen, startbild: p.einstellungen.uebersichtsbild, tankvolumen: p.fahrzeug.tankvolumen_liter, nachtVon: p.einstellungen.nacht_von, nachtBis: p.einstellungen.nacht_bis, markenname: p.einstellungen.kraftstoffanbieter, backupIntervall: p.einstellungen.backup_intervall || "aus", letztesBackup: p.einstellungen.letztes_backup || null, ausfuehrung: p.fahrzeug.ausfuehrung || "", // Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht // angeboten wurde (31.08. bis 03.09.2026); der Standard greift nur bei // neuen Profilen. Siehe fahrterkennung._pausenzeit_s im Backend. // Auf den Bereich des Reglers geklemmt: der ging bis zum 03.09.2026 ab 0 // ("sofort"), das Geraet kennt aber kein Schlaf-Timeout unter einer // Minute. Ein gespeicherter alter Wert stuende sonst als "0 Min." neben // einem Daumen, der bei 1 einrastet. pausenzeit: Number.isFinite(p.einstellungen.fahrten_pausenzeit_min) ? Math.min(60, Math.max(1, p.einstellungen.fahrten_pausenzeit_min)) : 15, }; } function profilZuCar(p, status) { // km ist kein abgeleiteter Wert mehr, sondern wird vom Backend // (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene // Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz. const reifenSatz = (k) => p.reifen.saetze[k] || {}; return { fin: p.fahrzeug.fin, // Default true (auch für Profile von vor dieser Funktion, die das Feld // noch nicht kennen) - deckt sich mit identitaet.py's eigenem // fahrzeug.get("fin_automatisch", True). finAutomatisch: p.fahrzeug.fin_automatisch !== false, details: p.fahrzeug.details, erstzulassung: p.fahrzeug.erstzulassung, odo: status.km ?? 0, odoBekannt: status.km != null, tankPct: status.tankprozent ?? 0, tankBekannt: status.tankprozent != null, reichweite: status.reichweite_km, batteriespannung: status.batteriespannung, gesichert: status.gesichert, sicherheitscheck: status.sicherheitscheck || [], zuendung: status.zuendung ?? null, geparktSeit: status.geparkt_seit ?? null, faehrtSeit: status.faehrt_seit ?? null, standortLat: status.standort_lat, standortLon: status.standort_lon, standortGenauigkeitM: status.standort_genauigkeit_m, standortZeit: status.standort_zeit, oelwechselFaelligTs: status.oelwechsel_faellig_ts, oelwechselFaelligKm: status.oelwechsel_faellig_km, inspektionFaelligTs: status.inspektion_faellig_ts, inspektionFaelligKm: status.inspektion_faellig_km, kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert // "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag // selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne // dass der Nutzer manuell eingreifen muss. smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab || p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab }, oel: { modus: p.einstellungen.oelwechsel_intervall.modus, km: p.einstellungen.oelwechsel_intervall.km, monate: p.einstellungen.oelwechsel_intervall.monate, herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km, herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate, }, versicherung: p.versicherung, steuer: { ...p.steuer, faellig: p.steuer.faellig || "" }, reifen: { aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer", anzugsmoment: p.reifen.anzugsmoment_nm, wechsel: p.reifen.wechsel, sommer: reifenSatz("sommer"), winter: reifenSatz("winter"), // Neueste zuerst, schon so im Profil (reifen.py archivieren()). Nur // gelesen - bearbeitet/gelöscht wird über eigene Dienste // (reifen_archiv_aktualisieren/-loeschen), nicht über profilSpeichern, // aus demselben Grund wie km oben: kein Stand im Browser darf einen // zwischenzeitlichen Servereintrag überschreiben. archiv: p.reifen.archiv || [], }, service: p.service, technik: p.technik, ausstattung: p.ausstattung, }; } /* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */ function configCarZuProfil(vorherigesProfil) { const p = JSON.parse(JSON.stringify(vorherigesProfil)); p.fahrzeug.titel = CONFIG.fahrzeugtitel; p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; p.fahrzeug.kennzeichen = CONFIG.kennzeichen; p.fahrzeug.fin = CAR.fin; p.fahrzeug.fin_automatisch = CAR.finAutomatisch; p.fahrzeug.erstzulassung = CAR.erstzulassung; p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung; p.einstellungen.uebersichtsbild = CONFIG.startbild; p.einstellungen.backup_intervall = CONFIG.backupIntervall; p.einstellungen.fahrten_pausenzeit_min = CONFIG.pausenzeit; p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb }; p.einstellungen.oelwechsel_intervall = { modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate, hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate, }; p.versicherung = CAR.versicherung; p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null }; p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase(); p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment; p.reifen.wechsel = CAR.reifen.wechsel; for (const k of ["sommer", "winter"]) { // km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf // den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im // Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung // überschreiben. const { km, ...rest } = CAR.reifen[k]; p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest }; } p.service = CAR.service; return p; } let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil() async function profilSpeichern() { const neuesProfil = configCarZuProfil(PROFIL_ROH); PROFIL_ROH = neuesProfil; // HIG "Feedback": profilSpeichern() ist der Speicherpfad fuer praktisch // jedes editierbare Feld im Panel (~25 Aufrufstellen) und wurde bisher an // keiner davon awaited oder abgefangen - ein Fehlschlag war eine unhandled // promise rejection ohne jede Rueckmeldung. Zentral hier gefangen, aus // demselben Grund wie serviceRufen() weiter unten. try { await HASS.callService(DOMAIN, "profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); } catch (err) { console.error("audi_dashboard: profilSpeichern", err); hinweis("Speichern fehlgeschlagen", err && err.message ? err.message : "Die Änderung konnte nicht gespeichert werden."); } } /* ------------------------------------------------------------- Bilder Feste Dateinamen laut §7a. Diese Fotos lädt der Nutzer selbst hoch; sie liegen deshalb unter /local/bilder/ (also in /config/www/bilder/) und nicht bei den ausgelieferten Dateien der Integration - dort würde sie das nächste Update mitlöschen (siehe bilder.py). Solange ein Foto fehlt, behalten die Flächen ihre Maße und zeigen einen Platzhalter, das Layout springt beim Nachliefern also nicht. */ const BILDER = [ { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" }, { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" }, { name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" }, { name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" }, { name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" }, { name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" }, { name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" }, // Steht auf der Seite "Fahrzeug" über den Sicherheitszeilen. Bis zum // 02.09.2026 war sie der einzige sichtbare Bildplatz, der in den // Einstellungen NICHT auswählbar war - hochladen ließ sie sich damit gar // nicht (gemeldet vom Eigentümer). bilder.py kannte den Dateinamen längst. /* nurStatus: gehoert zur Fahrzeugseite und nur dorthin (Ansage des Eigentuemers, 02.09.2026) - die Galerie unter "Mein Audi" ueberspringt sie, in der Auswahl der Einstellungen bleibt sie stehen, weil das der einzige Weg ist, das Foto hochzuladen. */ { name: "Draufsicht", label: "Auto von oben", datei: "draufsicht.webp", nurStatus: true }, ]; /* Die Plaetze, die die Galerie durchblaettert. */ const GALERIE = BILDER.filter((b) => !b.nurStatus); let bild = 0; const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" }; /* Der Stand eines Fahrzeugfotos, als Anhaengsel an seiner Adresse. Home Assistant liefert /local/ mit 31 Tagen Cache-Vorgabe aus; ohne wechselndes Anhaengsel zeigt der Browser nach einem Upload weiter das alte Bild oder den zwischenzeitlich gespeicherten 404. Bis zum 04.09.2026 stand hier `Date.now()` - eine Adresse, die sich bei JEDEM Start aendert. Der Zwischenspeicher griff damit zwischen zwei Starts nie, und mit dem Vorladen aller Fotos waeren das 5,3 MB je Start gewesen. Jetzt kommt der Zeitstempel jeder Datei vom Backend (bilder.staende(), veroeffentlicht an sensor.audi_dashboard_app_version): er aendert sich genau dann, wenn das Foto wirklich ein anderes ist. bildVersion bleibt als Rueckfall fuer ein Backend, das die Staende noch nicht mitliefert - dann ist das Verhalten wie zuvor. */ let BILD_STAENDE = null; let bildVersion = Date.now(); function bildStand(datei) { if (!BILD_STAENDE) return bildVersion; // 0 heisst: dieses Foto gibt es nicht. Sobald es hochgeladen wird, // aendert sich die Adresse von selbst - auch ein gespeicherter 404 // loest sich damit auf. return BILD_STAENDE[datei] ?? 0; } /* null, wenn das Backend meldet, dass es dieses Foto nicht gibt (Stand 0). Vorher wurde die Adresse trotzdem gebaut und der Browser holte bei JEDEM Rendern eine 404 - am 04.09.2026 gemessen: fuenf Anfragen fuer front-schraeg.webp in einem einzigen Ladevorgang, dazu heckansicht, cockpit, sitze und seitenansicht-winter. Die App weiss laengst, dass es sie nicht gibt; sie muss nicht danach fragen. */ function bildAdresse(datei) { const stand = bildStand(datei); if (BILD_STAENDE && stand === 0) return null; return `/local/bilder/${datei}?v=${stand}`; } /* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt - damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss, ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS- Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */ /* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen (Bild der Übersicht: Ansicht wählen, dann auf das Bild tippen). Die Maße bleiben über die CSS-Klassen erhalten, das Layout springt beim Nachliefern eines Fotos nicht. */ function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) { const idTeil = imgId ? ` id="${imgId}"` : ""; // Ohne Adresse KEIN src-Attribut - ein leeres src holt die Seite selbst // erneut, und ein ganz ohne src zeigt das kaputte Bildsymbol. // Sichtbar bleibt es nicht: .bildbox.bild-fehlt img ist ausgeblendet. // Das selbst bleibt trotzdem stehen, weil die Galerie es weiter- // schaltet (#carimg) - ohne das Element bliebe sie stehen. const srcTeil = src ? ` src="${src}"` : ""; const fehltTeil = src ? "" : " bild-fehlt"; return `
${esc(label)}
${esc(label)} ${dateiZeigen ? esc(datei) : "Foto hinzufügen"}
`; } function startIndex() { const i = BILDER.findIndex((b) => b.name === CONFIG.startbild); return i < 0 ? 0 : i; } /* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird. WARUM: der Upload laeuft als Dienstaufruf ueber die WebSocket-Verbindung, und die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und Base64 blaeht die Datei vorher um ein Drittel auf. Ueber die reale Instanz kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein koennen. Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der Testinstanz (localhost) durch und scheiterte ueber die reale Instanz mit "connection lost" - eine abgerissene Verbindung, kein Fehler des Backends, deshalb auch keine brauchbare Meldung. Verkleinern loest das an der Wurzel statt die Grenze zu verschieben: die groesste Flaeche, auf der ein Foto je erscheint, ist die Inhaltsspalte im Breitbildlayout mit 772px. 2000px lange Kante lassen dafuer reichlich Luft (auch fuer Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt ein solches Foto typischerweise auf einige hundert Kilobyte. Faellt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder, Ergebnis groesser als das Original), bleibt es beim Original - schlechter als vorher wird es dadurch nie. */ const BILD_MAX_KANTE = 2000; const BILD_QUALITAET = 0.85; function bildAlsBase64(blob) { return new Promise((fertig, fehler) => { const leser = new FileReader(); leser.onload = () => fertig(String(leser.result).split(",")[1] || ""); leser.onerror = () => fehler(leser.error); leser.readAsDataURL(blob); }); } /* Formate, die jeder Browser dieser App wirklich anzeigen kann. Wichtig, weil das Backend nichts prueft: bilder.py schreibt die Bytes roh unter einen der elf festen .webp-Namen. Ein Foto, das der Browser nicht umwandeln konnte und das er auch nicht anzeigen kann, laege danach als Bilddatei auf der Platte, die niemand darstellt - ohne Meldung. Der praktische Fall ist HEIC: die iPhone-Fotoauswahl liefert in der Regel JPEG, am Mac im Finder gewaehlt kommt aber eine .heic an, und die kann ausser Safari/WKWebView niemand dekodieren. */ const BILD_SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i; const BILD_SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i; function bildformatIstSicher(datei) { // Der MIME-Typ zaehlt, wenn er da ist. Aus dem Explorer gezogene Dateien // kommen je nach Quelle mit leerem type an - dann die Endung, dieselbe // Doppelpruefung wie bei istPdf(). if (datei.type) return BILD_SICHERE_TYPEN.test(datei.type); return BILD_SICHERE_ENDUNGEN.test(datei.name); } async function bildVerkleinern(datei) { let klein = null; const adresse = URL.createObjectURL(datei); try { const bild = await new Promise((fertig, fehler) => { const i = new Image(); i.onload = () => fertig(i); i.onerror = () => fehler(new Error("Bild nicht lesbar")); i.src = adresse; }); const kante = Math.max(bild.naturalWidth, bild.naturalHeight); if (kante) { const faktor = Math.min(1, BILD_MAX_KANTE / kante); const flaeche = document.createElement("canvas"); flaeche.width = Math.round(bild.naturalWidth * faktor); flaeche.height = Math.round(bild.naturalHeight * faktor); flaeche.getContext("2d").drawImage(bild, 0, 0, flaeche.width, flaeche.height); klein = await new Promise((fertig) => flaeche.toBlob(fertig, "image/webp", BILD_QUALITAET), ); } } catch (err) { console.warn("Bild konnte nicht umgewandelt werden", err); } finally { URL.revokeObjectURL(adresse); } // Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch // eine verlustbehaftete Kodierung - das kostet Qualitaet ohne Gewinn. if (klein && klein.size < datei.size) return klein; /* Umwandeln ging nicht (oder brachte nichts) - dann muss wenigstens das Original anzeigbar sein. Sonst lieber jetzt eine klare Meldung als hinterher ein leeres Bild, dessen Ursache niemand mehr sieht. */ if (!bildformatIstSicher(datei)) { const art = datei.type || datei.name.split(".").pop() || "unbekannt"; throw new Error( `Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` + "Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.", ); } return datei; } function bildInfo(i) { const b = BILDER[i]; if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") { return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: bildAdresse(SEITE_WINTER.datei) }; } return { label: b.label, datei: b.datei, src: bildAdresse(b.datei) }; } /* Alle Fahrzeugfotos einmal vorab holen UND dekodieren. Gemeldet als "Bilder laden beim Umschalten der Menues verzoegert". Am Netz liegt es nicht: die Adresse ist innerhalb einer Sitzung stabil und Home Assistant liefert /local/ mit 31 Tagen Cache-Dauer. Verzoegert wird das Bild selbst - ein frisch erzeugtes wird standardmaessig asynchron dekodiert und erst danach gezeichnet, und render() erzeugt bei jedem Seitenwechsel ein neues. decode() erledigt genau diesen Schritt vorab. Laeuft in der Leerlaufzeit, damit es nicht mit dem ersten Datenabruf konkurriert, und nur einmal je Bildstand. */ let bilderVorgeladen = null; function bilderVorladen() { const dateien = [...BILDER.map((b) => b.datei), SEITE_WINTER.datei]; // Kennung ueber alle Staende: solange sich kein Foto geaendert hat, // laeuft das Vorladen kein zweites Mal - und der Browser holt die Bilder // beim naechsten Start aus seinem eigenen Zwischenspeicher. const kennung = dateien.map(bildStand).join(","); if (bilderVorgeladen === kennung) return; bilderVorgeladen = kennung; const starten = () => { for (const datei of dateien) { const img = new Image(); img.src = bildAdresse(datei); // Ein nicht hochgeladenes Foto ist der Normalfall - still schlucken. if (img.decode) img.decode().catch(() => {}); } }; if (window.requestIdleCallback) window.requestIdleCallback(starten, { timeout: 4000 }); else setTimeout(starten, 2000); } function carImg(modus) { if (modus === "fest") { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein"); } const info = bildInfo(bild); /* data-bildgalerie: ein Tipp irgendwo auf die Kachel (Foto oder Platzhalter) schaltet zum naechsten Bild weiter - Hochladen/Ersetzen/Loeschen bleibt bewusst allein den Einstellungen vorbehalten (Bild der Übersicht), deshalb hier kein data-bildupload/data-bildklick, nur bildWeiter(). Der Wrapper ist noetig, weil das selbst bei fehlendem Foto per .bildbox.bild-fehlt img{display:none} unsichtbar und damit unklickbar ist - der bisherige Handler (id="carimg") griff dann ins Leere. */ return `
${/* "klein": das Bild bestimmt die Hoehe. Im festen 16:9-Kasten mit object-fit:cover verlor die Frontansicht unten die Stossstange (gemeldet 02.09.2026) - jedes Foto, dessen Verhaeltnis nicht 16:9 ist, wurde beschnitten. Dieselbe Entscheidung wie bei der Uebersicht-Szene (31.08.) und der Vorschau in den Einstellungen. */"" }${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein", "carimg")}
${GALERIE.map((b) => ``).join("")}
`; } /* Bildmarke des Kraftstoffanbieters (Shell). Anders als bei den Fahrzeug- fotos ist hier kein generischer Platzhalter-Rahmen sinnvoll, weil das Logo inline neben Text steht (.marke) - Fehlerzustand blendet stattdessen den Markennamen als kleine Textmarke ein (.marke .ph in audi-dashboard.css), exakt wie im Prototyp vorgesehen. Die Bilddatei selbst liefert der Nutzer nach (offizielles Shell-Logo, hier nicht nachgezeichnet). */ const MARKENLOGO_DATEI = "shell-logo.svg"; function markenlogo() { if (!CAR.smartdeal.aktiv) return ""; const label = CONFIG.markenname || "Logo"; return ``; } /* Der angedeutete Boden unter dem Fahrzeug ist am 2026-08-31 entfallen - der Eigentuemer wollte den Schatten weg, und damit hatte auch der Schalter dafuer nichts mehr zu schalten. Frueher stand hier szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter" an .szene haengte; die zugehoerigen ::after-Regeln sind in audi-dashboard.css beschrieben. */ /* Ein Bild weiter (richtung 1) oder zurueck (-1). Die Richtung kam am 04.09.2026 dazu: der Eigentuemer wollte die Galerie auch durch Wischen nach links/rechts durchschalten, nicht nur durch Tippen (galerieWischenVerdrahten unten, wortgleich mit der App). */ function bildWeiter(richtung = 1) { // Ueber die Galerie-Plaetze, nicht ueber alle: die Draufsicht wird // uebersprungen (siehe nurStatus in BILDER). const jetzt = GALERIE.findIndex((b) => b === BILDER[bild]); const anzahl = GALERIE.length; if (!anzahl) return; const naechst = GALERIE[(Math.max(0, jetzt) + richtung + anzahl) % anzahl]; bild = BILDER.indexOf(naechst); const img = ROOT.getElementById("carimg"); if (!img) return; const info = bildInfo(bild); // Ohne Adresse das Attribut ENTFERNEN statt auf null zu setzen: src="" // laesst den Browser die Seite selbst erneut holen. if (info.src) img.src = info.src; else img.removeAttribute("src"); img.alt = info.label; const box = img.closest("[data-bildbox]"); if (box) { box.classList.toggle("bild-fehlt", !info.src); box.querySelector(".platzhalter-label").textContent = info.label; } const sz = ROOT.getElementById("szene"); if (sz) sz.className = "szene"; [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", GALERIE[i] === BILDER[bild])); } /* Wischen in der Bildergalerie (Wunsch des Eigentuemers, 04.09.2026). Zeigerereignisse statt Beruehrungsereignisse: dieselbe Verdrahtung traegt Finger, Stift und Maus, und genau so macht es das Standort-Blatt schon (standortMenuVerdrahten). Ein Wisch ab 40 px zaehlt als Wisch, alles darunter bleibt ein Tipp - dieselbe Schwelle wie dort. GEWISCHT SCHLUCKT DEN KLICK: ein Wisch loest anschliessend auch ein click aus. Ohne diese Sperre schaltete die Galerie zweimal weiter. */ let galerieZug = null; let galerieGewischt = false; function galerieWischenVerdrahten() { ROOT.addEventListener("pointerdown", (e) => { const feld = e.target.closest && e.target.closest("[data-bildgalerie]"); galerieZug = feld ? { x0: e.clientX, y0: e.clientY } : null; }); ROOT.addEventListener("pointerup", (e) => { const zug = galerieZug; galerieZug = null; if (!zug) return; const dx = e.clientX - zug.x0; // Senkrecht ueberwiegt: das war ein Scrollen, kein Blaettern. if (Math.abs(dx) <= 40 || Math.abs(e.clientY - zug.y0) > Math.abs(dx)) return; galerieGewischt = true; bildWeiter(dx < 0 ? 1 : -1); }); } /* ---------------------------------------------------- Termine/Vorhersage heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch das tatsächliche Tagesdatum ersetzt werden muss. */ function heute() { return new Date(); } const dTag = 86400000; /* Ein unlesbares Datum ergibt einen Strich, keine Rechenreste. dat() erwartet TT.MM.JJJJ; steht im Profil etwas anderes (es wird laut INSTALL.md von Hand gepflegt, z. B. versehentlich im ISO-Format), lieferte dedat() sonst "NaN.NaN.NaN" - ein Wert, der wie eine Angabe aussieht und keine ist. */ const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime()); const dedat = (d) => (gueltigesDatum(d) ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) : "–"); function dat(s) { // Nimmt seit dem 04.09.2026 auch ein fertiges Date entgegen: die // Hauptuntersuchung reicht ihre Basis als Date herein (siehe // datumAusText/hauptuntersuchungsTermin), monatePlus() ruft dat() darauf. if (s instanceof Date) return new Date(s.getTime()); const [d, m, j] = String(s).split(".").map(Number); return new Date(j, m - 1, d); } /* Freitext-Datum aus dem Profil - WORTGLEICH mit alsZeitpunkt() in companion-app/src/format.ts. Die Felder "Erstzulassung" und "Hauptuntersuchung" sind Eingabefelder, und der Eigentuemer traegt dort ein, was ihm richtig scheint. Am 04.09.2026 gemessen: das Panel leitete die Hauptuntersuchung nur dann aus der Erstzulassung ab, wenn diese ein "/" enthielt - bei "01.03.2025" (eine voellig richtige Eingabe) blieb der Termin still leer. `genau: "monat"` heisst: die Angabe nennt keinen Tag ("08/2026"). Daraus den Ersten des Monats zu ZEIGEN waere eine Erfindung - deshalb die Genauigkeit mitfuehren und in datumText() beruecksichtigen. */ const DATUM_ISO = /^(\d{4})-(\d{1,2})-(\d{1,2})$/; // Bindestrich ist mitgemeint - siehe DE_TAG in companion-app/src/format.ts. const DATUM_TAG = /^(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})$/; const DATUM_MONAT = /^(\d{1,2})[./](\d{4})$/; const DATUM_ISO_MONAT = /^(\d{4})-(\d{1,2})$/; function datumBauen(jahr, monat, tag) { if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null; const d = new Date(jahr, monat - 1, tag); // new Date(2025,1,31) waere der 3. Maerz - ein unmoegliches Datum soll "-" // ergeben, nicht klammheimlich ein anderes. if (d.getFullYear() !== jahr || d.getMonth() !== monat - 1 || d.getDate() !== tag) return null; return d; } function datumAusText(wert) { if (wert instanceof Date) return gueltigesDatum(wert) ? { zeit: wert, genau: "tag" } : null; const text = String(wert == null ? "" : wert).trim(); if (!text) return null; let m = DATUM_ISO.exec(text); if (m) { const d = datumBauen(+m[1], +m[2], +m[3]); return d ? { zeit: d, genau: "tag" } : null; } m = DATUM_TAG.exec(text); if (m) { const d = datumBauen(+m[3], +m[2], +m[1]); return d ? { zeit: d, genau: "tag" } : null; } m = DATUM_MONAT.exec(text); if (m) { const d = datumBauen(+m[2], +m[1], 1); return d ? { zeit: d, genau: "monat" } : null; } m = DATUM_ISO_MONAT.exec(text); if (m) { const d = datumBauen(+m[1], +m[2], 1); return d ? { zeit: d, genau: "monat" } : null; } return null; } /* Zeigt eine monatsgenaue Angabe als Monat statt als erfundenen Ersten. */ const demonat = (d) => (gueltigesDatum(d) ? d.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) : "–"); const datumText = (d, genau) => (genau === "monat" ? demonat(d) : dedat(d)); /* Die Erstzulassung, wie sie angezeigt wird: formatiert, wenn sie sich lesen laesst, sonst der Rohwert, sonst ein Strich. Bis zum 04.09.2026 stand hier immer der Rohwert und bei leerem Feld gar nichts - waehrend die FIN daneben "–" zeigte. Drei Leerdarstellungen in einer Kachel. Wortgleich mit datum(fahrzeug.erstzulassung) in der App. */ function erstzulassungText() { const z = datumAusText(CAR.erstzulassung); if (z) return datumText(z.zeit, z.genau); return (CAR.erstzulassung || "").trim() || "–"; } /* Datum + Uhrzeit in einer Zeile, für Start-/Zielzeitpunkt der Einzelfahrt. */ const dezeitpunkt = (d) => (gueltigesDatum(d) ? `${dedat(d)} · ${d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : "–"); const MISCHDAUER = 180; function tagesleistung() { // §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem // Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die // Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000. if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 }; const b = CAR.service.buch; if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 }; const alt = b[b.length - 1]; const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag); const langfristig = (CAR.odo - alt.km) / tageGesamt; const letzter = b[0]; const tageSeit = (heute() - dat(letzter.datum)) / dTag; const kmSeit = CAR.odo - letzter.km; if (tageSeit < 14 || kmSeit <= 0) return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km }; const aktuell = kmSeit / tageSeit; const g = Math.min(1, tageSeit / MISCHDAUER); return { wert: langfristig * (1 - g) + aktuell * g, quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt", tage: Math.round(tageSeit), km: kmSeit, }; } function kmProTag() { return tagesleistung().wert; } function prognose(zielKm) { if (!CAR.odoBekannt) return { tage: null, datum: null }; const rest = zielKm - CAR.odo; if (rest <= 0) return { tage: 0, datum: heute() }; const tage = Math.round(rest / kmProTag()); return { tage, datum: new Date(heute().getTime() + tage * dTag) }; } function ics(titel, datum, ort, notiz) { const tag = datum.replace(/-/g, ""); const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, ""); const text = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT", "UID:" + Date.now() + "@audi-dashboard.local", "DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z", "DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster, "SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""), "BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM", "END:VEVENT", "END:VCALENDAR", ].join("\r\n"); const a = document.createElement("a"); a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text); a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics"; document.body.appendChild(a); a.click(); a.remove(); } /* ------------------------------------------------------------------ Karte Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ let MAP = null, TILE = null, LEAFLET_LADEND = null; // Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren). let BELEG_STATION_POS = null; /* Merker, ob zu EINEM Beleg überhaupt eine Position zu finden war. Der Abruf ist asynchron, die Ansicht aber schon gezeichnet - ohne diesen Merker stünde bei einem erfolglosen Nachschlag dauerhaft eine Karte ohne Nadel da (samt Knopf, der nie scharf wird). `{ id, zustand: "keine" }`, sonst null. */ let BELEG_KARTE = null; /* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten dark_all-Kacheln liessen die Karte im Panel schwarz wirken. CARTOs anonyme basemaps.cartocdn.com-Kacheln (vorher hier) verlangen inzwischen einen eigenen API-Schlüssel - ohne ihn zeigten die Kacheln nur noch "API key required". Die Standard-OpenStreetMap-Kacheln sind weiterhin ohne Schlüssel nutzbar und decken diesen Bedarf (ein Fahrzeug, geringes Kartenaufkommen) ohne eigenen Dienst. */ const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const ATTR = "© OpenStreetMap contributors"; /* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */ const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; const SAT_ATTR = "Tiles © Esri"; const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; /* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head: Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head, fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px hoch), von der Karte war nur ein schmaler Streifen zu sehen und der Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */ function leafletCssEinhaengen() { if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve(); return new Promise((fertig) => { const css = document.createElement("link"); css.rel = "stylesheet"; css.href = LEAFLET_CSS_URL; css.dataset.leafletCss = "1"; /* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */ css.onload = fertig; css.onerror = fertig; ROOT.appendChild(css); }); } function leafletLaden() { /* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */ const css = leafletCssEinhaengen(); if (window.L) return css; if (!LEAFLET_LADEND) { LEAFLET_LADEND = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = LEAFLET_JS_URL; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } return Promise.all([css, LEAFLET_LADEND]); } function mapBox() { return `
`; } /* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert - die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf (siehe adresseAufloesen). */ function mapBoxMitZentrieren() { return `
`; } /* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte): die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt- Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */ function kartenMittelpunkt() { const pos = CAR.service.autohaus.pos; return pos && pos.length === 2 ? pos : [51.1657, 10.4515]; } function tiles() { if (!MAP || !window.L) return; const url = TILE_URL; if (TILE) MAP.removeLayer(TILE); TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); } /* Fahrtrichtung auf der Streckenlinie. Bis 2026-08-31 lagen auf der Linie kleine runde Punkte - je einer pro GPS-Stuetzpunkt. Sie sagten nichts ueber die Richtung, und bei einer Fahrt hin und zurueck auf derselben Strasse verschmolzen Hin- und Rueckweg zu einem einzigen roten Band: aus der Karte war nicht mehr abzulesen, dass es ueberhaupt zwei Wege waren. Die Form ist der Navigationspfeil, kein gleichschenkliges Dreieck: das Dreieck sieht von beiden Seiten gleich aus und laesst auf Abstand offen, wo vorn ist - besonders dort, wo die Strecke die Richtung wechselt. Die Kerbe im Fuss macht Kopf und Ende unterscheidbar (Nutzervorgabe mit Vorlage, 2026-08-31). Drei Entscheidungen, jede an Mustern nachgemessen (siehe unten): 1. Abstand nach STRECKE, nicht nach Stuetzpunkten. Die Punktdichte des Rohtracks haengt an der Geschwindigkeit - im Stau entsteht ein Haufen, auf der Autobahn eine Luecke. Pfeile daran haetten genau dort geklumpt, wo am wenigsten passiert ist. 2. Mittig auf der Linie, ohne Versatz quer zur Fahrtrichtung. Ein solcher Versatz war erprobt und trennt eine Hin-und-Rueck-Strecke sauber auf zwei Spuren - aber nur, wenn Hin- und Rueckweg wirklich Punkt fuer Punkt uebereinanderliegen. Das tun echte GPS-Aufzeichnungen nicht (Eigentum des Fahrzeughalters, 2026-08-31). Fuer einen Fall, der in den Daten nicht vorkommt, waere die schiefe Lage aller Dreiecke ein taeglicher Preis ohne Gegenwert. 3. Weisser Rand. Das Dreieck liegt in derselben Farbe auf derselben Linie; ohne die helle Kontur verschwimmt beides zu einer dicken Wurst. */ function spurMeter(a, b) { const R = 6371000, r = Math.PI / 180; const dLat = (b[0] - a[0]) * r, dLon = (b[1] - a[1]) * r; const h = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * r) * Math.cos(b[0] * r) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); } /* Winkel im Uhrzeigersinn ab Norden - genau das, was CSS rotate() erwartet. Die Laengendifferenz wird mit cos(Breite) gestaucht, sonst zeigten die Pfeile in unseren Breiten rund 30 Grad daneben. */ function spurWinkel(a, b) { const dx = (b[1] - a[1]) * Math.cos(((a[0] + b[0]) / 2) * Math.PI / 180); return Math.atan2(dx, b[0] - a[0]) * 180 / Math.PI; } function spurPfeile(karte, spur, farbe) { if (!Array.isArray(spur) || spur.length < 2 || !window.L) return; const laengen = []; let gesamt = 0; for (let i = 1; i < spur.length; i++) { const d = spurMeter(spur[i - 1], spur[i]); laengen.push(d); gesamt += d; } if (!gesamt) return; // Rund alle 350 m, aber nie weniger als 4 und nie mehr als 14: auf einer // 200-km-Fahrt waeren 570 Pfeile eine zweite Linie, auf 300 m gar keiner. const anzahl = Math.max(4, Math.min(14, Math.round(gesamt / 350))); const schritt = gesamt / (anzahl + 1); let ziel = schritt, gelaufen = 0; for (let i = 1; i < spur.length && ziel < gesamt; i++) { const d = laengen[i - 1]; while (ziel <= gelaufen + d && ziel < gesamt) { const f = d ? (ziel - gelaufen) / d : 0; const lat = spur[i - 1][0] + (spur[i][0] - spur[i - 1][0]) * f; const lon = spur[i - 1][1] + (spur[i][1] - spur[i - 1][1]) * f; const w = spurWinkel(spur[i - 1], spur[i]).toFixed(1); const html = '
' + '' + '' + '
'; window.L.marker([lat, lon], { icon: window.L.divIcon({ className: "spur-pfeil", html, iconSize: [13, 13], iconAnchor: [6.5, 6.5] }), interactive: false, }).addTo(karte); ziel += schritt; } gelaufen += d; } } async function initMap() { await leafletLaden(); const el = ROOT.getElementById("map"); if (!el || typeof window.L === "undefined") return; MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false }); TILE = null; tiles(); const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537"; if (route.name === "trip") { const t = TRIPS.find((x) => x.trip_id === route.id); if (t && t.start_lat != null && t.end_lat != null) { // Echte Streckenlinie aus dem GPS-Verlauf (route, screening.py), wenn // vorhanden - sonst eine ehrliche gerade Linie zwischen Start und Ziel, // keine erfundene Kurve mehr (siehe companion-app/Karte.tsx, dieselbe // Haltung: keine Route zu zeigen, die es so nicht gab). const track = Array.isArray(t.route) && t.route.length >= 2 ? t.route : [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]]; const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP); // Nur auf einer echten Aufzeichnung: die gestrichelte Ersatzlinie // zwischen Start und Ziel ist keine gefahrene Strecke, und ein Pfeil // darauf behauptete eine Fahrtrichtung, die niemand gemessen hat. if (Array.isArray(t.route) && t.route.length >= 2) spurPfeile(MAP, track, rot); window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); } else { MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten } } else if (route.name === "fill") { BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch const f = FILLS.find((x) => x.tank_id === route.id); const suche = f ? tankstelleSuchtext(f.station_name, f.station_address) : ""; if (f && f.station_lat != null) { tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f); } else if (suche) { /* Ohne Koordinaten die Anschrift vom Beleg nachschlagen - und wenn der Beleg keine trägt, den Namen (dieselbe Reihenfolge wie in der App, tankstelleSuchtext). Bis die Antwort da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen prüfen, ob wir noch auf demselben Beleg sind. */ MAP.setView(kartenMittelpunkt(), 11); const karte = MAP, belegId = route.id; tankstellePosition(f.station_name, f.station_address).then((pos) => { if (MAP !== karte || route.name !== "fill" || route.id !== belegId) return; if (pos) return tankstelleAufKarteZeigen(karte, pos, f); /* Kein Treffer: statt eines beliebigen Ausschnitts mit totem Zentrieren-Knopf die ehrliche Auskunft. Genau die beiden Befunde des Eigentümers vom 04.09.2026 - "Map lädt die Position der Tankstelle nicht" und "der Button auf der Map funktioniert nicht" waren dieselbe Stelle: eine Karte ohne Nadel und ein Knopf, der nie scharf wurde. */ BELEG_KARTE = { id: belegId, zustand: "keine" }; render(); }); } else { MAP.setView(kartenMittelpunkt(), 11); } } setTimeout(() => MAP && MAP.invalidateSize(), 60); } /* ---------------------------------------------------------------- Standort Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet- Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort- und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus (Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */ let USER_POS = null; // {lat, lon, genauigkeit} oder null let USER_POS_TS = 0; let USER_POS_FEHLER = null; let USER_WATCH = null; // watchPosition-Kennung, solange die Standortseite offen ist let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) let STANDORT_ADRESSE_KEY = null; let STANDORTMENU_OFFEN = false; let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit" let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel let FAHRZEUG_MARKER = null, USER_MARKER = null; const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss zu --standort-peek in audi-dashboard.css passen function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; } /* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25 im 48er- bzw. y=23.3 im 24er-viewBox). */ function fahrzeugMarkerSVG(px = 34) { const gross = px >= 34; const box = gross ? 48 : 24; const sil = gross ? CI.poiCarLSil : CI.poiCarSil; const icon = gross ? CI.poiCarL : CI.poiCar; return ``; } /* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug, nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander- zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */ function tankstellenMarkerSVG(px = 32) { const gross = px >= 34; const box = gross ? 48 : 24; const sil = gross ? CI.poiLSil : CI.poiSSil; const icon = gross ? CI.poiL : CI.poiS; // Der Ring im poi-Icon ist bei Kartengroesse zu duenn, um sich von der // dunklen Silhouette abzuheben - die Nadel las sich als schwarzer Fleck // (Nutzerbefund 04.09.2026). Der gefuellte Punkt schliesst genau den // Kreis, der im Pfad ohnehin steht (poi-s: 11.5/8.5 r3.5, poi-l: // 23.5/17.5 r7.5) - erfunden ist daran nichts, und keine Farbe aendert // sich: currentColor ist dasselbe Weiss wie die uebrigen Konturlinien. const punkt = gross ? '' : ''; return ``; } /* Fahrzustand fürs Menü: kommt aus der Zündung (CAR.zuendung, vom Backend aus demselben Sensor veröffentlicht, den auch fahrterkennung.py als maßgebliches Signal nimmt). Ist kein Zündungssensor zugeordnet, bleibt der Zustand ehrlich unbekannt, statt "steht" zu behaupten. Vorher stand hier `TRIPS[0].status === "offen"` - gleich zwei Fehler übereinander, die sich gegenseitig verstärkt haben: 1. "offen" heißt NICHT "unterwegs", sondern "Daten noch unvollständig" (die Strecke fehlt, siehe fahrtabschluss_logik.py). Eine beendete Fahrt ohne Kilometerstand - etwa weil zu dem Zeitpunkt kein KM_SENSOR zugeordnet war - bleibt für immer "offen". 2. TRIPS[0] ist die ÄLTESTE Fahrt, nicht die neueste: fahrten_veroeffentlichen() gibt profil.fahrten_lesen() unverändert in Dateireihenfolge weiter, und die ist aufsteigend. Zusammen hieß das: die älteste jemals unvollständig gebliebene Fahrt hat das Fahrzeug dauerhaft als "fahrend" angezeigt. An der Testinstanz war das eine Fahrt, die seit 18 Tagen beendet war. parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in "Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */ function letzteFahrt() { let neueste = null; for (const t of TRIPS) { if (!t || !t.ts_end) continue; if (!neueste || t.ts_end > neueste.ts_end) neueste = t; } return neueste; } /* Seit wann das Fahrzeug steht: aus dem Backend, hergeleitet aus dem Zeitpunkt, zu dem die Zuendung zuletzt ausging (in der Zeit des Geraets). Bis 2026-09-01 stand hier das Ende der juengsten Fahrt aus der Liste. Das war keine Aussage ueber das Parken, sondern ueber die Fahrterkennung: eine verpasste Fahrt laesst das Fahrzeug seit Tagen stehen, obwohl es gestern unterwegs war. Real vorgekommen - am 31.08.2026 zeigte die Anzeige "geparkt seit 2 Tg. 16 Std.", waehrend am Vorabend 7 km gefahren wurden. Kein Rueckfall auf die Fahrtenliste: null heisst unbekannt, und das wird auch so angezeigt. Eine falsche Zahl ist schlechter als keine. */ function standortZustand() { const roh = CAR.geparktSeit ? new Date(CAR.geparktSeit).getTime() : NaN; const seitTs = Number.isNaN(roh) ? null : roh; if (CAR.zuendung === true) return { faehrt: true, seitTs: null, parkplatz: false }; const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null; return { faehrt: false, unbekannt: CAR.zuendung == null, seitTs, parkplatz: minuten != null && minuten >= PARKPLATZ_AB_MIN, }; } /* Ab wann aus "steht gerade" ein "Parkplatz" wird. Hing bis 2026-08-31 an der Einstellung fahrten_pausenzeit_min. Die gibt es seit dem 03.09.2026 wieder ("Fahrt beenden"), aber der Schwellwert hier hat nach wie vor nichts damit zu tun und bleibt als eigene Zahl stehen. */ const PARKPLATZ_AB_MIN = 15; function dauerSeitText(ts) { if (ts == null) return "unbekannt"; const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); const tage = Math.floor(sekunden / 86400); const stunden = Math.floor((sekunden % 86400) / 3600); const minuten = Math.floor((sekunden % 3600) / 60); if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`; if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; return `${minuten} Min.`; } /* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */ function distanzMeter(a, b) { if (!a || !b) return null; const R = 6371000, rad = Math.PI / 180; const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad; const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; } /* Zeile unter dem Fahrzeugnamen: Abstand Nutzer -> Fahrzeug, sonst der Grund, warum er noch fehlt. */ function standortAbstandText() { if (!standortBekannt()) return ""; if (USER_POS) return distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) + " entfernt"; return USER_POS_FEHLER || "Abstand wird ermittelt …"; } function standortErfassen() { if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; } navigator.geolocation.getCurrentPosition( (pos) => { USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy }; USER_POS_TS = Date.now(); USER_POS_FEHLER = null; standortAnsichtAktualisieren(); }, (err) => { // Diese Zeile steht direkt unter dem Fahrzeugnamen, an der Stelle, wo // sonst der Abstand zum Auto steht - sie handelt aber vom Standort // DIESES Geräts, nicht vom Fahrzeug. "Standortzugriff verweigert" las // sich dort so, als sei der Standort des Autos nicht abrufbar. // // Die verweigerte Freigabe bleibt trotzdem eigens benannt: sie ist der // einzige Fall mit einer anderen Abhilfe (Freigabe erteilen statt auf // GPS-Empfang warten), und "GPS offline" würde dort in die falsche // Richtung schicken. USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline"; // Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die // Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die // Freigabe verweigert - und jedes Backend-Update loeste eine neue // Hochgenauigkeits-Abfrage aus. USER_POS_TS = Date.now(); standortAnsichtAktualisieren(); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } ); } function standortErfassenFallsNoetig() { // Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener // Versuch kein Versuch, siehe Fehlerpfad oben. if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return; standortErfassen(); } /* Fortlaufende Ortung, solange die Standortseite offen ist - "live bei Verwendung". Die einmalige Abfrage oben bleibt daneben bestehen und ist weiterhin das Richtige fuer die Uebersichtskachel: die zeigt nur einen Abstand, und eine dauerhafte Hochgenauigkeits-Ortung auf der Startseite waere Batterieverbrauch ohne Gegenwert. Beendet wird die Verfolgung an denselben zwei Stellen, an denen auch SMAP abgeraeumt wird - sonst liefe sie nach dem Verlassen der Seite unbemerkt weiter. Genau das verlangt Apples Vorgabe fuer "Beim Verwenden der App": erfassen, solange der Nutzer die Karte ansieht, und keine Sekunde laenger. */ function standortVerfolgungStarten() { if (USER_WATCH !== null) return; if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; } USER_WATCH = navigator.geolocation.watchPosition( (pos) => { USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy }; USER_POS_TS = Date.now(); USER_POS_FEHLER = null; standortAnsichtAktualisieren(); }, (err) => { // Dieselbe Unterscheidung wie in standortErfassen(): die verweigerte // Freigabe ist der einzige Fall mit einer anderen Abhilfe. USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline"; USER_POS_TS = Date.now(); standortAnsichtAktualisieren(); }, // maximumAge klein: eine zwischengespeicherte Position aus der letzten // halben Minute waere hier das Gegenteil von "live". { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 } ); } function standortVerfolgungBeenden() { if (USER_WATCH === null) return; navigator.geolocation.clearWatch(USER_WATCH); USER_WATCH = null; } /* Der Ort einer Fahrtgrenze, solange er noch nicht aufgelöst ist. Der Backend füllt start_address/end_address NIE von selbst - sie stehen in HANDFELDER und kommen nur aus einer von Hand angelegten oder bearbeiteten Fahrt. Die Koordinaten dagegen trägt das Screening ein. Deshalb stand auf der Einzelfahrt bisher "unbekannt", obwohl die Position bekannt war (vom Eigentümer gemeldet, 01.09.2026). Aufgelöst wird seit dem 01.09.2026 im Backend (geokodierung.py) und mit der Fahrt gespeichert. Der Client fragt nichts mehr ab und hält keinen eigenen Zwischenspeicher: jedes Gerät zeigt denselben Ort, ein frisch eingerichtetes Telefon zeigt ihn sofort, und ein geleerter Browserspeicher wirft nichts weg. "wird ermittelt …" heißt: die Koordinaten stehen, der Ort noch nicht. Das nächste Screening trägt ihn nach. */ function fahrtOrtText(adresse, lat, lon) { if (adresse) return esc(adresse); if (lat == null || lon == null) return "unbekannt"; return "wird ermittelt …"; } /* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an derselben Stelle keinen zweiten Abruf auslöst. */ /* Arten, bei denen der Eigenname aussagekraeftiger ist als die Anschrift. */ const PLATZ_ARTEN = new Set(["parking", "parking_space", "parking_entrance", "bicycle_parking", "motorcycle_parking", "rest_area", "services", "fuel", "charging_station"]); async function standortAdresseAufloesen(lat, lon) { const key = lat.toFixed(3) + "," + lon.toFixed(3); if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE; /* Dauerhafter Cache im selben Fach wie die Tankstellensuche, Schluessel mit "rev:"-Vorsatz. Bis 2026-08-30 lag hier nur ein einziger Punkt im Arbeitsspeicher, weg beim naechsten Neuladen - mit der Begruendung, die Fahrzeugposition aendere sich ohnehin laufend. Das stimmt fuers Fahren, nicht fuers Parken: das Auto steht meist an denselben Stellen, und wenn Nominatim gerade drosselt, stand an einer laengst bekannten Stelle wieder nur ein Koordinatenpaar. */ const cacheKey = "rev:" + key; const cache = geoCacheLesen(); if (typeof cache[cacheKey] === "string") { STANDORT_ADRESSE = cache[cacheKey]; STANDORT_ADRESSE_KEY = key; return STANDORT_ADRESSE; } try { const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } }); const daten = await res.json(); const a = daten.address || {}; const strasse = [a.road, a.house_number].filter(Boolean).join(" "); const ort = [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" "); /* Steht das Auto auf einem benannten Platz - Parkplatz, Parkhaus, Rastplatz -, sagt dessen Name mehr als die naechstgelegene Hausnummer. Nominatim liefert ihn im Feld name; die Pruefung auf die Art der Flaeche verhindert, dass jedes zufaellig benannte Gebaeude die Anschrift verdraengt. */ const platz = PLATZ_ARTEN.has(daten.type) ? (daten.name || "").trim() : ""; STANDORT_ADRESSE = platz ? [platz, ort].filter(Boolean).join(", ") : [strasse, ort].filter(Boolean).join(", ") || daten.display_name || ""; } catch (e) { // Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner // fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle // die ganze Sitzung lang als "schon versucht", und es blieben dauerhaft nur // die Koordinaten stehen. STANDORT_ADRESSE = null; return null; } STANDORT_ADRESSE_KEY = key; // Nur echte Treffer merken - eine gedrosselte Antwort ist kein "hier gibt es // keine Adresse" und soll beim naechsten Mal erneut versucht werden. if (STANDORT_ADRESSE) geoCacheSchreiben(cacheKey, STANDORT_ADRESSE); return STANDORT_ADRESSE; } function standortAdresseLaden() { if (!standortBekannt()) return; standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => { const el = $("#standortAdresse"); if (el && adresse) el.textContent = adresse; }); } /* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet, dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse, die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */ const GEO_CACHE_KEY = "audi_dashboard_geocache"; let GEO_CACHE = null; function geoCacheLesen() { if (GEO_CACHE) return GEO_CACHE; try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; } catch (e) { GEO_CACHE = {}; } return GEO_CACHE; } function geoCacheSchreiben(schluessel, wert) { const cache = geoCacheLesen(); cache[schluessel] = wert; try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); } catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ } } async function adresseAufloesen(adresse) { const suche = (adresse || "").trim(); if (!suche) return null; const cache = geoCacheLesen(); if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche]; let treffer = null; try { const res = await fetch( `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`, { headers: { "Accept-Language": "de" } } ); const daten = await res.json(); if (Array.isArray(daten) && daten.length) { treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) }; } } catch (e) { // Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der // nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen). return null; } geoCacheSchreiben(suche, treffer); return treffer; } /* ------------------------------------------------------- Tankstelle Anzeige in zwei Zeilen und der Weg in den Kartendienst des Geraets. Wortgleich mit companion-app/src/daten/tankstelle.ts - dort steht die ausfuehrliche Begruendung. Kurz: die Anschrift kommt vom Belegparser als ", "; geteilt wird am LETZTEN Komma, damit ein Zusatz bei der Strasse bleibt. Erste Zeile Tankstelle und Strasse, zweite Zeile der Ort (Vorgabe des Eigentuemers, 04.09.2026). Ohne Komma bleibt alles in der ersten Zeile - lieber ein langer erster Eintrag als ein erfundener Ort. */ /* Kopfmarken des deutschsprachigen Marktes. WORTGLEICH mit _MARKEN in shell_beleg_parser.py und MARKEN in companion-app/src/daten/tankstelle.ts - der Parser erkennt die Marke beim Einlesen, diese Liste erkennt sie nachtraeglich in dem, was gespeichert ist. Reihenfolge zaehlt: "TotalEnergies" muss vor "Total" stehen. */ const MARKEN = [ "TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV", "Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth", "Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET", "bft", "BP", "Q1", ]; /* Die Kopfmarke aus dem gespeicherten Stationsnamen - null, wenn keine bekannte darin steht. `station_name` traegt je nach Alter des Belegs Verschiedenes: seit dem 16.08.2026 setzt der Parser "Marke, Strasse, Ort" zusammen, aeltere Belege und jeder ohne erkennbare Marke tragen dort die Firmierung des Betreibers ("A. Zrenner GmbH"). Die ist nach aussen nicht sichtbar und gehoert nicht in die Anzeige (Vorgabe des Eigentuemers, 04.09.2026). Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. */ /* Nimmt einem Namensteil die fuehrende Kopfmarke ab - wortgleich mit ohneMarkeVorn() in companion-app/src/daten/tankstelle.ts. Bis zum 04.09.2026 wurde ein Namensteil nur uebersprungen, wenn er FUER SICH eine Marke ist. Ein von Hand eingetragener Name ohne Komma ("Shell München Ost") rutschte komplett durch und landete als ORT: "Shell, Musterstr., Shell München Ost". Jetzt faellt die Marke vorn ab. Steht die Marke NICHT am Anfang ("Autohof Shell Nord"), bleibt der Teil unangetastet - dort ist nicht sicher, was Marke und was Ortsname ist. */ function ohneMarkeVorn(teil) { const marke = markeErkennen(teil); if (!marke) return teil; // Kein Maskieren noetig: marke stammt immer aus MARKEN, und dort steht // kein einziges Sonderzeichen der regulaeren Ausdruecke. const muster = new RegExp(`^${marke}\\b[\\s,]*`, "i"); return muster.test(teil) ? teil.replace(muster, "").trim() : teil; } function markeErkennen(name) { const roh = (name || "").trim(); if (!roh) return null; for (const marke of MARKEN) { const muster = new RegExp("\\b" + marke.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b", "i"); if (muster.test(roh)) return marke; } return null; } /* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist. */ /* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige. */ const FIRMA_MUSTER = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i; const PLZ_ORT_MUSTER = /^(\d{4,5})\s+(.+)$/; /* Greedy bis zum letzten Nicht-Ziffernzeichen, damit auch die zusammengeschriebene Form der Belege trifft: "Nürnberger Str.74". */ const STRASSE_NR_MUSTER = /^(.*\D)\s*(\d+\s*[a-zA-Z]?)$/; /* "Nürnberger Straße" -> "Nürnberger Str.". Die Grossschreibung des Treffers entscheidet, sonst stuende mitten im Wort ein grosses S. */ function strasseKuerzen(strasse) { return strasse.replace(/stra(?:ß|ss)e\b\.?/gi, (t) => (t[0] === t[0].toUpperCase() ? "Str." : "str.")); } /* Zerlegt, was ein Beleg ueber die Tankstelle hergibt. Der Bestand hat zwei Bauformen, am 04.09.2026 an den echten Datensaetzen nachgesehen: bis 2025: station_name = "TC Sengül GmbH" (der Betreiber!) station_address = "Nürnberger Str.74, 91522 Ansbach" ab 08.2026: station_name = "Shell, Pascalstr. 8, Ingolstadt" station_address = null Die Markenerkennung lief bis hierher nur ueber den Namen - bei den aelteren Belegen steht die Marke dort gar nicht, deshalb blieb in der Uebersicht die Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026), waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden jetzt beide Felder. `marke` ist die gespeicherte Angabe des Belegs (station_brand). Sie geht vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke dastand, obwohl "Shell Station" auf ihm steht. */ function tankstelleTeile(name, adresse, marke) { const teile = { marke: (marke || "").trim() || markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() }; const quellen = (adresse || "").split(","); for (const stueck of (name || "").split(",")) { // Die Marke faellt vorn ab - sie steht schon fest und taugt nie als Ort. // Bleibt nichts uebrig, war der Teil nur die Marke selbst. const t = ohneMarkeVorn(stueck.trim()); if (t) quellen.push(t); } for (const stueck of quellen) { const t = stueck.trim(); if (!t || FIRMA_MUSTER.test(t)) continue; const plzOrt = PLZ_ORT_MUSTER.exec(t); if (plzOrt) { if (!teile.plz) teile.plz = plzOrt[1]; if (!teile.ort) teile.ort = plzOrt[2].trim(); continue; } const mitNummer = STRASSE_NR_MUSTER.exec(t); if (mitNummer) { if (!teile.strasse) { teile.strasse = strasseKuerzen(mitNummer[1].trim()); teile.hausnummer = mitNummer[2].replace(/\s+/g, ""); } continue; } /* Weder Postleitzahl noch Hausnummer: der Ort. Eine Strasse ohne Nummer laesst sich davon nicht sicher unterscheiden - im Zweifel der Ort, weil er in der Uebersicht die wichtigere Angabe ist. */ if (!teile.ort) teile.ort = t; } return teile; } /* Zeile der Uebersicht: Marke, Strasse ohne Hausnummer, Ort ohne Postleitzahl (Vorgabe des Eigentuemers, 04.09.2026). Ergibt die Zerlegung nichts, bleibt der rohe Name stehen - ihn wegzulassen hiesse, eine bekannte Tankstelle "unbekannt" zu nennen. */ function tankstelleKurz(name, adresse, marke) { const t = tankstelleTeile(name, adresse, marke); return [t.marke, t.strasse, t.ort].filter(Boolean).join(", ") || t.roh || "unbekannt"; } /* Die beiden Zeilen im Einzelbeleg: oben Marke und Strasse mit Hausnummer, unten Postleitzahl und Ort - geschrieben wie eine Anschrift, das Komma trennt nur die Marke ab. `oben` ist nie leer. */ function tankstelleZeilen(name, adresse, marke) { const t = tankstelleTeile(name, adresse, marke); const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" "); let oben = [t.marke, strasse].filter(Boolean).join(", "); let unten = [t.plz, t.ort].filter(Boolean).join(" "); if (!oben && !unten) return { oben: t.roh || "unbekannt", unten: "" }; if (!oben) { oben = unten; unten = ""; } return { oben, unten }; } /* Suchbegriffe fuer die Geokodierung, in der Reihenfolge des Versuchs: erst die Anschrift, dann der Name. NICHT beides zusammen - am 04.09.2026 gemessen: "Pascalstr. 8, 85057 Ingolstadt" findet Nominatim, mit dem Betreibernamen davor nicht. Der Name ALLEIN ist trotzdem wertvoll ("Shell, SOELDEN" findet es), deshalb der zweite Versuch. */ /* Dritter Versuch: der Name OHNE die fuehrende Marke. Seit dem 16.08.2026 setzt der Belegparser den Namen als "Marke, Strasse, Ort" zusammen; fehlt darin die Postleitzahl, kippt die Marke die Suche. Am 04.09.2026 gemessen: "Shell, Pascalstr. 8, Ingolstadt" findet Nominatim NICHT, "Pascalstr. 8, Ingolstadt" dagegen schon. Genau daran blieb die Karte auf der realen Instanz leer. */ function tankstelleKandidaten(name, adresse) { const n = (name || "").trim(); const marke = markeErkennen(n); const ohneMarke = marke && n.toLowerCase().startsWith(marke.toLowerCase()) ? n.slice(marke.length).replace(/^[ ,]+/, "").trim() : ""; return [...new Set([(adresse || "").trim(), n, ohneMarke].filter(Boolean))]; } /** Der erste Suchbegriff - leer heisst: der Beleg nennt gar nichts. */ function tankstelleSuchtext(name, adresse) { return tankstelleKandidaten(name, adresse)[0] || ""; } /* Ziel fuer den Kartendienst. Hier gehoeren Name UND Anschrift zusammen: Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die Zapfsaeule statt des Nachbargebaeudes. */ function tankstelleZiel(name, adresse, marke) { const a = (adresse || "").trim(); if (!a) return (name || "").trim(); return [(marke || "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", "); } /* Die Kandidaten der Reihe nach nachschlagen, erster Treffer gewinnt. */ async function tankstellePosition(name, adresse) { for (const begriff of tankstelleKandidaten(name, adresse)) { const pos = await adresseAufloesen(begriff); if (pos) return pos; } return null; } /** Apple-Geraet? Dort ist Karten der Standarddienst. */ function apfelgeraet() { const kennung = `${navigator.userAgent || ""} ${navigator.platform || ""}`; return /iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(kennung); } /* modus "route" berechnet den Weg, "zeigen" setzt nur eine Nadel. Der Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentuemers, 04.09.2026): erst sehen, wo sie liegt - eine Route erwartet dort niemand, und sie ist im Kartendienst einen Tipp entfernt. */ function kartendienstUrl(name, adresse, pos, modus = "route", marke) { const beschriftung = (name || "").trim(); const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke); if (!ziel) return null; if (apfelgeraet()) { if (modus === "zeigen") { return pos ? `https://maps.apple.com/?ll=${ziel}&q=${encodeURIComponent(beschriftung || "Ziel")}` : `https://maps.apple.com/?q=${encodeURIComponent(ziel)}`; } const q = pos && beschriftung ? `&q=${encodeURIComponent(beschriftung)}` : ""; return `https://maps.apple.com/?daddr=${encodeURIComponent(ziel)}${q}`; } return modus === "zeigen" ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ziel)}` : `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ziel)}`; } function kartendienstUrlZuPunkt(pos) { return pos ? kartendienstUrl(null, null, pos) : null; } /* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon steht. */ function tankstelleAufKarteZeigen(karte, pos, f) { if (!karte || !window.L || !pos) return; karte.setView([pos.lat, pos.lon], 14); const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${tankstellenMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); // Dieselben zwei Zeilen wie in der Wertezeile darunter (tankstelleZeilen) - // die Nadel soll nicht die Firmierung des Betreibers nennen, wenn die Zeile // die Kopfmarke zeigt. const zeilen = tankstelleZeilen(f.station_name, f.station_address, f.station_brand); window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte) .bindPopup(esc(zeilen.oben) + (zeilen.unten ? "
" + esc(zeilen.unten) : "")); // Position merken und den Knopf scharf schalten - er kann erst jetzt etwas // Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe // Aufrufstelle in initMap). BELEG_STATION_POS = pos; const knopf = ROOT && ROOT.getElementById("belegZentrieren"); if (knopf) knopf.disabled = false; } function belegAufTankstelleZentrieren() { if (!MAP || !BELEG_STATION_POS) return; MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14)); } function standortKartenlink() { return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null; } async function standortTeilen() { const url = standortKartenlink(); if (!url) return; const titel = `${CONFIG.fahrzeugtitel} · Standort`; if (navigator.share) { try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ } return; } try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); } catch (e) { hinweis("Teilen nicht möglich", url); } } /* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */ function standortAnsichtAktualisieren() { if (route.name !== "standort") return; standortMarkerZeichnen(); const dist = $("#standortDistanz"); if (dist) dist.textContent = standortAbstandText(); } function standortTiles() { if (!SMAP || !window.L) return; if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE); SMAP_TILE = SMAP_STIL === "satellit" ? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP) : window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP); } function standortMarkerZeichnen() { if (!SMAP || !window.L) return; if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; } if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; } if (standortBekannt()) { const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(38)}
`, iconSize: [38, 38], iconAnchor: [19, 36] }); FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP); FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen()); } if (USER_POS) { const icon = window.L.divIcon({ className: "user-pin-wrap", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); USER_MARKER = window.L.marker([USER_POS.lat, USER_POS.lon], { icon, zIndexOffset: -100 }).addTo(SMAP); } } function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); } function standortAufUser() { // Noch keine Position: die Verfolgung neu anstossen. Das ist zugleich der // Weg zurueck, wenn die Freigabe erst nachtraeglich erteilt wurde - ein // einmal gescheiterter watchPosition-Auftrag meldet sich nicht von selbst // wieder. if (!USER_POS) { standortVerfolgungBeenden(); standortVerfolgungStarten(); return; } if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15)); } function standortBeideZeigen() { if (!SMAP) return; if (standortBekannt() && USER_POS) SMAP.fitBounds([[CAR.standortLat, CAR.standortLon], [USER_POS.lat, USER_POS.lon]], { padding: [64, 64], maxZoom: 16 }); else if (standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], 15); else if (USER_POS) SMAP.flyTo([USER_POS.lat, USER_POS.lon], 15); } async function initStandortMap() { STANDORTMENU_OFFEN = false; await leafletLaden(); const el = ROOT.getElementById("smap"); if (!el || typeof window.L === "undefined") return; SMAP = window.L.map(el, { zoomControl: false, attributionControl: false }); SMAP_TILE = null; standortTiles(); /* Erst Ansicht, dann Marker: Leaflet legt Marker-Symbole nur auf einer Karte mit gesetztem Mittelpunkt an - vorher angehaengte Marker blieben unsichtbar. */ if (standortBekannt()) SMAP.setView([CAR.standortLat, CAR.standortLon], 15); else if (USER_POS) SMAP.setView([USER_POS.lat, USER_POS.lon], 15); else SMAP.setView(kartenMittelpunkt(), 11); standortMarkerZeichnen(); standortBeideZeigen(); standortAdresseLaden(); standortVerfolgungStarten(); setTimeout(() => SMAP && SMAP.invalidateSize(), 60); } async function initVorschauMap() { await leafletLaden(); const el = ROOT.getElementById("tmap"); if (!el || typeof window.L === "undefined") return; TMAP = window.L.map(el, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, }); TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP); if (standortBekannt()) { TMAP.setView([CAR.standortLat, CAR.standortLon], 14); const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP); } else { TMAP.setView(kartenMittelpunkt(), 11); } standortErfassenFallsNoetig(); setTimeout(() => TMAP && TMAP.invalidateSize(), 60); } function standortMenuElement() { return ROOT.getElementById("standortmenu"); } function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; } function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; } function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); } /* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette, bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse, anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar Standort-Abschnitt). */ let smY0 = null, smOffenBeimStart = false; /* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt, deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */ function standortMenuHub() { const el = standortMenuElement(); return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0; } function standortMenuVerdrahten() { ROOT.addEventListener("pointerdown", (e) => { if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return; smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN; const el = standortMenuElement(); if (el) el.style.transition = "none"; }); ROOT.addEventListener("pointermove", (e) => { if (smY0 === null) return; e.preventDefault(); const el = standortMenuElement(); if (!el) return; const dy = e.clientY - smY0; const hub = standortMenuHub(); const basis = smOffenBeimStart ? 0 : hub; const versatz = Math.max(0, Math.min(hub, basis + dy)); el.style.transform = `translateY(${versatz}px)`; }, { passive: false }); ROOT.addEventListener("pointerup", (e) => { if (smY0 === null) return; const dy = e.clientY - smY0; standortMenuGesteBeenden(); if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); } else standortMenuUmschalten(); }); /* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der pointermove-Handler oben ruft dann preventDefault() fuer jede weitere Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein pointerup kommt, der dann ein dy aus einem veralteten Startpunkt auswertet. Die drei anderen Zeigergesten der Datei machen das laengst richtig. */ ROOT.addEventListener("pointercancel", () => { if (smY0 === null) return; standortMenuGesteBeenden(); }); } /* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */ function standortMenuGesteBeenden() { const el = standortMenuElement(); if (el) { el.style.transition = ""; el.style.transform = ""; } smY0 = null; } function standortKachel() { return `
`; } /* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */ const CI = { /* fuel-station-s aus dem Audi-CI-Satz (24er-Raster) - die Zapfsaeule des Tanken-Reiters. Ersetzt seit dem 02.09.2026 eine handgezeichnete Saeule. Die 48er-Variante hiesse fuel-station-l und wird nicht gebraucht: die Reiter zeichnen mit 21 bzw. 26px, also im 24er-Raster. */ fuelStationS: '', /* settings-s aus dem Audi-CI-Satz (24er-Raster) - das Zahnrad in der rechten oberen Ecke einer Kachel. Stand bis zum 02.09.2026 sechsmal wortgleich im Markup; die 48er-Variante hiesse settings-l und wird hier nirgends gebraucht, das Zahnrad wird immer 17px dargestellt. */ settingsS: '', mapLayer: '', carSearch: '', gps: '', poiCar: '', /* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */ /* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu unterscheiden). Siehe tankstellenMarkerSVG(). */ poiL: '', poiS: '', poiCarL: '', /* Vollton-Silhouetten fuer die Kartennadeln: poi-car/poi sind reine Kontur- formen (Ring aus zwei gegenlaeufigen Randlinien + duenne Detaillinien im Inneren) - eine Pixelprobe ergab nur 13-40% gefuellte Flaeche und war auf Kartenkacheln praktisch unsichtbar (Nutzer-Feedback "Pin ist transparent/ durchsichtig"). Jede Silhouette ist exakt das erste Teilpfad-Segment des jeweiligen CI-Pfads oben (die aeussere Ballonform, vor dem Punkt, der die Ring-Innenlinie startet) - keine Neuzeichnung, nur der bereits im CI-Satz enthaltene Umriss, mit fester Farbe statt currentColor gefuellt. Wird in fahrzeugMarkerSVG()/tankstellenMarkerSVG() als unterste Ebene hinter dem Original-Icon (dann weiss statt dunkel) gerendert. */ poiCarLSil: '', poiCarSil: '', poiLSil: '', poiSSil: '', selectAll: '', planRoute: '', upload: '', /* share-l/-s aus dem Audi-CI-Satz (vom Nutzer als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Raster wie poi-l/poi-s. Loest beim Teilen das upload-Icon ab, das dort nur ersatzweise stand. */ shareL: '', shareS: '', parking: '', /* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */ battery12vL: '', battery12vS: '', /* list-l/-s, polls-l/-s, package-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. list: Sprung vom Diagramm zur Werteliste (Batteriespannung). polls: Statistik-Tab in der Tableiste. package: "Neue Räder anlegen". */ listL: '', listS: '', pollsL: '', pollsS: '', packageL: '', packageS: '', /* edit-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */ editL: '', editS: '', /* oil-change-l/-s, inspection-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. Die -s-Variante steht links neben der Kennzahl im Nächster-Service-Block (vHome()) statt des vorherigen "bis zum/zur..." Textes - die Serviceart wird jetzt ueber das Icon erkannt, nicht ueber Worte. */ oilChangeL: '', oilChangeS: '', inspectionL: '', inspectionS: '', }; function ciSVG(pfad, px = 16) { return ``; } function vStandort() { const zustand = standortZustand(), bekannt = standortBekannt(); const distanz = standortAbstandText(); const adresseKey = bekannt ? CAR.standortLat.toFixed(3) + "," + CAR.standortLon.toFixed(3) : null; /* Solange die Adresse nicht aufgeloest ist, stehen die Koordinaten da - kein Platzhaltertext, der haengen bleiben kann. */ const koordinaten = bekannt ? CAR.standortLat.toFixed(4) + ", " + CAR.standortLon.toFixed(4) : ""; const adresseText = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : STANDORT_ADRESSE_KEY === adresseKey ? (STANDORT_ADRESSE || koordinaten) : koordinaten; const link = standortKartenlink(); return `
${esc(CONFIG.fahrzeugtitel)}
${distanz}
${esc(adresseText)}
${zustand.faehrt /* Der Text sagt "Zuendung an", nicht "Fahrzeug faehrt". Es gibt keinen Bewegungssensor am Fahrzeug - der einzige Anhaltspunkt ist ZUENDUNG_SENSOR, und der meldet on auch bei blosser Zuendung oder Zubehoerstellung. Auch die erkannte Fahrt hilft nicht weiter: fahrt_start_ts in fahrterkennung.py wird aus genau demselben Signal abgeleitet, waere also nur eine zweite Huelle um dieselbe Aussage. Der umgekehrte Fall ist unbedenklich: Zuendung aus heisst verlaesslich, dass gerade nicht gefahren wird - "steht"/"Geparkt seit" bleiben deshalb stehen. */ ? `Zündung an` : zustand.unbekannt ? `${ciSVG(CI.parking)}Fahrzustand unbekannt` : `${ciSVG(CI.parking)}${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht, Parkdauer unbekannt"}`}
${ciSVG(CI.fuelStationS, 16)} ${de(CAR.tankPct)} % ${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"}
${ciSVG(CI.planRoute, 15)}Route
`; } /* -------------------------------------------------------------- Ansichten Ab hier praktisch unverändert aus dem Prototyp übernommen. */ function badge() { // Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen, // negative = für dunklen Hintergrund) - welche Variante sichtbar ist, // entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in // audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces // beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig. // // Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus // dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix // ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt // aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten // CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE). const praefix = badgePraefix(); if (!praefix) return ""; const datei = BADGE_DATEIEN[praefix]; const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim(); /* Die Ausfuehrung steht oft schon im Modellnamen ("RS 4 Avant competition" plus Ausfuehrung "competition") - dann nicht ein zweites Mal anhaengen, sonst steht dort "Avant competition competition". */ const ausfuehrung = (CONFIG.ausfuehrung || "").trim(); const doppelt = ausfuehrung && modellZusatz.toLowerCase().includes(ausfuehrung.toLowerCase()); const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" "); const alt = esc(CONFIG.fahrzeugtitel) + " Badge"; return `
${alt} ${alt} ${zusatz ? `${esc(zusatz)}` : ""}
`; } function teaser() { if (!TRIPS.length && !FILLS.length) return ""; /* Bis 2026-08-30 stand hier TRIPS[0]/FILLS[0]. Beide Listen kommen vom Backend aufsteigend sortiert (vTrips()/vFuel() geben sie unveraendert in dieser Reihenfolge aus, aelteste Zeile zuerst) - Index 0 war damit der AELTESTE Datensatz, obwohl die Kachel "Letzte Fahrt"/"Letzter Tankvorgang" heisst. Beim Paritaetsabgleich mit der companion-app aufgefallen, die hier von Anfang an nach Zeitstempel sortiert hat. Jetzt beide gleich: der zeitlich juengste Datensatz. */ const juengster = (liste, feld) => liste.length ? liste.slice().sort((a, b) => new Date(b[feld]) - new Date(a[feld]))[0] : null; const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts"); return `
Zuletzt ${CAR.faehrtSeit ? `
${/* Kein Knopf und keine Wertespalte: solange die Fahrt laeuft, gibt es weder Strecke noch Verbrauch. Ein Strich behauptete, ein Wert fehle - richtig ist, dass es ihn noch nicht gibt. */""} Aktuelle Fahrtläuft
` : t ? `` : ""} ${f ? `` : ""}
`; } // Kandidaten ohne ziel (kein Servicebucheintrag, keine Fahrzeugmeldung, bei // der Hauptuntersuchung auch keine verwertbare Erstzulassung) müssen VOR dem // Sortieren raus: (a,b) => a.ziel - b.ziel zwingt null auf 0 (Epoch 1970) und // liess einen datenlosen Termin dadurch immer als "naechster" gewinnen - // selbst gegen einen anderen Termin mit echtem, berechnetem Datum. null nur // dann zurückgeben, wenn wirklich kein einziger Termin ein ziel hat. function naechsterTermin() { const mitZiel = termine().filter((t) => t.ziel !== null); return mitZiel.length ? mitZiel.slice().sort((a, b) => a.ziel - b.ziel)[0] : null; } // CI-Icon je Serviceart statt eines Wort-Zusatzes ("bis zum Ölwechsel" o.ä.) - // nur Ölwechsel/Inspektion haben eines, die Hauptuntersuchung nicht. Verwendet // von vHome() (Nächster-Service-Block) und vService() (Anstehende Termine). // Das Ölwechsel-Icon ist bei gleicher Boxgröße optisch kleiner als das // Inspektion-Icon (füllt in seinem eigenen 24x24-Raster nur eine 24x10-Fläche // statt fast der vollen Höhe) - deshalb eigene, größere Boxgröße statt einer // gemeinsamen Konstante. const ART_ICON = { "Ölwechsel": CI.oilChangeS, "Inspektion": CI.inspectionS }; const ART_ICON_GROESSE = { "Ölwechsel": 26, "Inspektion": 20 }; function intervalle(t) { const oel = CAR.oel, b = t.basis; if (!b) return null; const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) }); if (t.art === "Hauptuntersuchung") { const eig = bauen(null, 24); return { hersteller: null, eigen: eig, prognose: eig.datum }; } const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000; const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24; const eig = bauen(eKm, eM); // "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten" // tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist // "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten // immer die Herstellerwerte zeigen und das Kundenintervall ignorieren. const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24); const perKm = prognose(eig.km).datum; return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum }; } /* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht: die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu nichts meldet (Sensor fehlt/nicht verfügbar). */ function fahrzeugMeldungRoh(art) { if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm }; if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm }; return { ts: null, km: null }; } /* App-eigene Service-Prognose, eigenständig statt über die allgemeine tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum der letzten tatsächlich durchgeführten Arbeit dieser Art. Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither wenig gefahren wurde. Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */ function servicePrognose(stichwort, intervallKm, intervallMonate) { if (!CAR.odoBekannt) return null; const letzter = letzterEintrag(stichwort); if (!letzter) return null; const zielKm = letzter.km + intervallKm; const restKm = Math.max(0, zielKm - CAR.odo); const kappDatum = monatePlus(letzter.datum, intervallMonate); let datum; if (restKm <= 0) { datum = heute(); } else { const tageSeit = (heute() - dat(letzter.datum)) / dTag; const kmSeit = CAR.odo - letzter.km; if (tageSeit <= 0 || kmSeit <= 0) { // Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B. // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das // Zeitlimit als einzig belastbare Grenze zeigen. datum = kappDatum; } else { const kmProTagSeither = kmSeit / tageSeit; const tageBisZiel = restKm / kmProTagSeither; const berechnet = new Date(heute().getTime() + tageBisZiel * dTag); datum = berechnet < kappDatum ? berechnet : kappDatum; } } return { km: zielKm, restKm, datum }; } /* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), unabhängig vom Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */ function kmProTagAusFahrten() { const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null); if (!gueltig.length) return null; const km = gueltig.reduce((s, t) => s + t.distance_km, 0); const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime())); const tage = (heute().getTime() - aeltesteZeit) / dTag; return tage >= 1 ? km / tage : null; } /* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne Servicebucheintrag (siehe Aufrufer in vService()) - ein echter Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten Service. Restkilometer aus der Meldung, hochgerechnet mit der echten Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze - fährt der Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */ function meldungsPrognose(art) { const fm = fahrzeugMeldungRoh(art); const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null; if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null; const restKm = Math.abs(fm.km); if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm }; const rate = kmProTagAusFahrten(); if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null; const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag); const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum); return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm }; } /* Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in "Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall- Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet - eine Näherung (der wahre letzte Service kann näher oder ferner liegen), aber ohne sie würde die eigene Intervallwahl bis zum ersten Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */ function meldungsPrognoseEigenesIntervall(fm, herstellerKm, herstellerMonate, eigenKm, eigenMonate) { if (fm.km == null || !herstellerKm || eigenKm == null) return null; const restKm = Math.max(0, Math.abs(fm.km) - herstellerKm + eigenKm); let zeitDatum = null; if (fm.ts && gueltigesDatum(new Date(fm.ts)) && herstellerMonate != null && eigenMonate != null) { zeitDatum = new Date(fm.ts); zeitDatum.setMonth(zeitDatum.getMonth() + (eigenMonate - herstellerMonate)); } if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm }; const rate = kmProTagAusFahrten(); if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null; const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag); const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum); return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm }; } /* Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe), bei eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall() hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst (kein Sensor dafür), fällt das sanft auf die reine Meldung zurück. */ function oelMeldungsPrognose() { const oel = CAR.oel; if (oel.modus === "hersteller") return meldungsPrognose("Ölwechsel"); const fm = fahrzeugMeldungRoh("Ölwechsel"); const eigen = meldungsPrognoseEigenesIntervall(fm, oel.herstellerKm, oel.herstellerMonate, oel.km, oel.monate); return eigen || meldungsPrognose("Ölwechsel"); } function oelwechselPrognose() { const oel = CAR.oel; return servicePrognose("öl", oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm), oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)); } /* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine(). Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */ function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); } /* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und Service-Seite identisch aufgebaut. Leer, solange kein passender Servicebucheintrag vorliegt. */ function prognoseZeile(art) { const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null; if (!p) return ""; const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : ""; return `
${km}voraussichtlich am ${dedat(p.datum)}`; } function terminText(t) { const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null; const restKmAbs = restKm !== null ? Math.abs(restKm) : null; // datumText statt dedat: eine monatsgenaue Faelligkeit ("08/2026") darf nicht // als erfundener Erster des Monats erscheinen. const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : datumText(t.datum, t.genau); const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum) : (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Wartungsplan fortgeschrieben")); return { haupt, neben, restKm }; } /* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus- Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24, dann Tage. */ function alterText(ts) { if (ts == null || !Number.isFinite(ts)) return "unbekannt"; const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); if (sekunden < 60) return `vor ${sekunden} Sek.`; const minuten = Math.floor(sekunden / 60); if (minuten < 60) return `vor ${minuten} Min.`; const stunden = Math.floor(minuten / 60); if (stunden < 24) return `vor ${stunden} Std.`; return `vor ${Math.floor(stunden / 24)} Tage`; } function standAlterText() { return alterText(LETZTES_UPDATE_TS); } function standAlterTicken() { if (!ROOT || !CAR) return; const sy = $("#sync span"); if (sy) sy.textContent = standAlterText(); } function vHome() { const g = CAR.gesichert; const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt"; const statusDot = g === null ? "warn" : g ? "" : "bad"; const pct = CAR.tankPct; const t = naechsterTermin(); /* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst nicht kann. "Zuletzt" steht seit der Designpruefung 2026-08-30 vor der Standortkarte: zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */ return `
${carImg("fest")}
${badge()} ${esc(CONFIG.kennzeichen)}
${CAR.reichweite != null ? de(CAR.reichweite) : "–"}km
${CAR.tankBekannt ? pct + " %" : "–"}
Reichweite
${(() => { /* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich große Kacheln nebeneinander - gleiches Maß, gleiche Zahlengröße, gleiche Farbe, obwohl nur eine davon ein Knopf war und die beiden Zahlen Grundver- schiedenes meinen: der Kilometerstand ist ein gemessener Ist-Wert, der sich kaum ändert, der Service-Wert ein Countdown, der täglich kleiner wird. Zwei gleich große Zahlen nebeneinander behaupteten damit eine Gleichrangigkeit, die inhaltlich nicht besteht. Jetzt: eine führende Kennzahl, darunter der Kilometerstand als ruhige Wertezeile, getrennt durch eine Haarlinie - Hierarchie über Größe und Farbe (Audi-CI) kombiniert mit Apple HIGs "differentiate controls from content": der Chevron sitzt nur am Serviceblock, damit erkennbar bleibt, welcher Teil auf die Service-Seite führt und welcher reine Anzeige ist. Beides zusammen in einer normalen .tile mit Hintergrund - die Haarlinie trennt dann innerhalb der Kachel, statt die Kachel zu ersetzen. */ const kmZeile = `
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
`; if (!t) return `
Nächster Service:
kein Eintrag im Wartungsplan
${kmZeile}
`; // naechsterTermin() waehlt bereits ueber alle drei Arten hinweg (Oelwechsel, // Inspektion, Hauptuntersuchung) den zeitlich naechsten aus. const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null; const datum = t.kmDatum || t.datum; const hauptwert = restKm !== null ? `${de(restKm)}km` : (datum === t.datum ? datumText(t.datum, t.genau) : (datum ? dedat(datum) : "–")); // Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch // jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027" // auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px // weiterhin einzeilig bleibt). const zahlGroesse = 40; // Icon statt "bis zum Ölwechsel"/"bis zur Inspektion" (nur fuer die beiden // Arten, die eines haben). Die Hauptuntersuchung hat kein Restkilometer-Ziel // und zeigt stattdessen den Art-Namen selbst unter dem Datum. const artIcon = ART_ICON[t.art]; const nebenzeile = restKm !== null && datum ? `voraussichtlich am ${dedat(datum)}` : t.art; const figMarkup = artIcon ? `
${ciSVG(artIcon, 31)}
${hauptwert}
` : `
${hauptwert}
`; return `
${kmZeile}
`; })()} ${teaser()} ${standortKachel()}`; } /* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt) statt eines einfarbigen Punkts - Zustand ist so schon am Symbol selbst erkennbar, nicht nur an der Farbe. Nur für die Sicherheit-Ansicht (vSicherheit()/ vTuerenKlappen()) gedacht, der übrige Bestand behält den einfachen .dot. */ function statusKreis(ok) { const klasse = ok === null ? "warn" : ok ? "" : "bad"; const glyph = ok === null ? '' : ok ? '' : ''; return ``; } /* Gruppiert die flache CAR.sicherheitscheck-Liste (siehe _sicherheitscheck() in veroeffentlichung.py) nach ihrem "gruppe"-Feld. ok ist null, sobald ein einziger Punkt der Gruppe unbekannt ist, oder wenn die Gruppe ganz ohne Einträge dasteht (kein Sensor dieser Rolle im Setup-Menü zugeordnet) - nie true durch eine leere Liste erschlichen. */ function sicherheitsGruppe(gruppe) { const eintraege = CAR.sicherheitscheck.filter((e) => e.gruppe === gruppe); const ok = eintraege.length === 0 || eintraege.some((e) => e.ok === null) ? null : eintraege.every((e) => e.ok); return { eintraege, ok }; } function vSicherheit() { const g = CAR.gesichert; const verriegelt = sicherheitsGruppe("verriegelt"); const tuerenKlappen = sicherheitsGruppe("tueren_klappen"); const fensterDach = sicherheitsGruppe("fenster_dach"); const licht = sicherheitsGruppe("licht"); const sammelText = g === null ? "Zustand nicht vollständig bekannt" : g ? "Fahrzeug ist sicher abgestellt" : "Bitte prüfen"; const zeile = (label, ok, labelBad, labelWarn, route) => { const text = ok === null ? labelWarn : ok ? label : labelBad; const klickbar = !!route; return `<${klickbar ? "button" : "div"} class="row sicherheitszeile"${klickbar ? ` data-go="${route}:x"` : ""}> ${statusKreis(ok)} ${esc(text)} ${klickbar ? '' : ""} `; }; /* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen - dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ return `
${bildMitPlatzhalter(bildAdresse("draufsicht.webp"), "draufsicht.webp", "Draufsicht", "carfix klein")}
${statusKreis(g)} ${esc(sammelText)}
${zeile("Fahrzeug verriegelt", verriegelt.ok, "Fahrzeug nicht verriegelt", "Verriegelung unbekannt")} ${zeile("Türen und Klappen geschlossen", tuerenKlappen.ok, "Türen oder Klappen offen", "Türen/Klappen unbekannt", "tuerenklappen")} ${zeile("Fenster und Dach geschlossen", fensterDach.ok, "Fenster oder Dach offen", "Fenster/Dach unbekannt")} ${zeile("Licht ausgeschaltet", licht.ok, "Licht eingeschaltet", "Licht unbekannt")}
`; } /* Detailansicht zu "Türen und Klappen geschlossen" - jede Tür einzeln, Motorhaube, Kofferraum. Einzige der vier Sammelzeilen mit eigener Detailseite (siehe vSicherheit()), im Stil der früheren flachen "Geprüfte Punkte"-Liste. */ function vTuerenKlappen() { const eintraege = sicherheitsGruppe("tueren_klappen").eintraege; return `
Türen und Klappen
${eintraege.length ? eintraege.map((e, i, arr) => `
${esc(e.label)}
${e.ok === null ? "unbekannt" : e.ok ? "zu" : "offen"}
`).join("") : `
Keine Daten verfügbar
`}
`; } function vAudi() { const r = CAR.reifen, a = r[r.aktiv.toLowerCase()]; return `
${carImg("galerie")}
${badge()} ${esc(CONFIG.kennzeichen)}
Zustand
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) + " km" : "unbekannt"}
Tankfüllung
${CAR.tankPct} % / ${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l
Batteriespannung
${CAR.batteriespannung != null ? de(CAR.batteriespannung, 1) + " V" : "unbekannt"}
`; } /* ------------------------------------------------- Batteriespannungs-Verlauf Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt, siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt. bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit Zoom/Pan flüssig bleiben. */ /* bvPunkte enthält nur Ruhespannungs-Messwerte (<= AGM_RUHE_MAX_V) - sowohl Grundlage für Ladezustand/SOH-Trend als auch für das Diagramm selbst. Generatorspannungen (> AGM_RUHE_MAX_V, z. B. 13,01 V) tauchen dort bewusst nicht auf (Nutzerwunsch: "13,01 shall be generator und not part of the diagram") - anders als in einer Zwischenfassung, die kurzzeitig jede Messung zeichnete; die Messwertliste (vBatterieliste()) zeigt weiterhin ausnahmslos alle Tage samt "Generatorspannung"-Kennzeichnung, sie liest direkt aus BATTERIEVERLAUF, nicht aus bvPunkte. */ let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null; const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger) let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste} let bvTapKandidat = null, bvBewegtSeit = false; /* BV_W ist nur der Startwert: bvZeichnen() setzt die viewBox auf die tatsächlich gerenderte Pixelbreite (bvBreite), damit eine Einheit im Koordinatensystem genau einem Bildschirmpixel entspricht. Vorher stand hier eine feste 320er-viewBox mit preserveAspectRatio="none" - auf der 736px breiten Desktop-Spalte wurde dadurch alles um Faktor 2,3 in die Breite gezogen, sichtbar an den unnatürlich breit gelaufenen Achsenzahlen und an überlappenden Beschriftungen. BV_MB ist klein, seit die Datumsbeschriftung unter dem Diagramm entfällt (Datum steht beim Antippen im Tooltip). */ const BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 12; const BV_W = 320; let bvBreite = BV_W; const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }); /* Referenzwerte für AGM-Starterbatterien (12V). Zwei unterschiedliche Zahlen, nicht eine: AGM_RUHE_MAX_V ist die Klassifizierungsgrenze "gilt diese Messung überhaupt noch als Batterie(ruhe)spannung, nicht schon Generatorspannung" (Diagramm/SOH-Trend) - vom Nutzer direkt auf 13,0 V festgelegt ("max voltage for AGM Battery is 13V, everything above is the generator"). BV_SOC_TABELLE (unten) ist davon unabhängig die Ladezustands-Kurve selbst - deren "100 % / voll"-Eckwert bleibt bei den ursprünglich recherchierten 12,8 V (aboutdarwin.com/agm-battery-voltage- charts, brsbattery.com: volle Ruhespannung), vom Nutzer ausdrücklich bestätigt ("12,8v voll was already right"). Eine Messung zwischen 12,8 und 13,0 V zählt also als gültige Batteriemessung (Diagrammpunkt, fließt in SOH ein), zeigt aber weiterhin 100 % SOC - dieselbe Klemmung, die die Tabelle für jeden Wert >= ihrem letzten Eckwert ohnehin schon anwendet. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - deutlich über beiden Grenzen, die Trennung zur Generatorspannung bleibt also eindeutig. Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf, ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet, und zwar auf den Tagesminimalwert: der fällt typischerweise auf den Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet), tauchen entsprechend nicht in der Ladezustands-Statistik auf. */ const AGM_RUHE_MAX_V = 13.0; const BV_SOC_TABELLE = [ [10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40], [12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100], ]; function bvSocProzent(v) { const t = BV_SOC_TABELLE; if (v <= t[0][0]) return 0; if (v >= t[t.length - 1][0]) return 100; for (let i = 0; i < t.length - 1; i++) { const [v0, s0] = t[i], [v1, s1] = t[i + 1]; if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0)); } return 100; } function bvSocLabel(soc) { return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"; } // Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei // Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) - // dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok), // damit die Skala zum Rest der App passt statt eigene Farben zu erfinden. function bvHexZuRgb(hex) { const h = hex.trim().replace("#", ""); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); } function bvFarbstufen() { const stil = getComputedStyle(HOST); return { bad: bvHexZuRgb(stil.getPropertyValue("--bad")), warn: bvHexZuRgb(stil.getPropertyValue("--warn")), ok: bvHexZuRgb(stil.getPropertyValue("--ok")), }; } function bvFarbe(v, stufen) { let rgb; if (v <= 11.6) rgb = stufen.bad; else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6)); else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2)); else rgb = stufen.ok; return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; } /* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of- health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt, nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen eingeordnet (übliche Lebensdauer 4-6 Jahre). */ function bvSohBewertung() { const TAG_MS = 86400000; if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." }; const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS; if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." }; const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS; const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs); const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs); const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length; const delta = neuD - altD; const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"; const text = delta >= -0.05 ? "Stabil - kein auffälliger Rückgang der Ruhespannung." : delta >= -0.15 ? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen." : "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."; return { status, text, delta }; } function vBatterieverlauf() { // Ein Punkt je Tag mit einer echten Ruhespannungsmessung (<= AGM_RUHE_MAX_V, // z. B. 13,0 V), gezeichnet wird der Tagesminimalwert - min_ts ist der // echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver // Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. Der // Tageshöchstwert bekommt bewusst KEINEN eigenen Punkt, er steht beim // Antippen im Tooltip. Tage, deren Minimum über der Grenze liegt (nie // wirklich in Ruhe beobachtet, sondern nur Generatorspannung), tauchen im // Diagramm nicht auf (Nutzerwunsch) - die Messwertliste zeigt sie weiterhin // alle, mit "Generatorspannung"-Kennzeichnung. bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min_ts && e.min <= AGM_RUHE_MAX_V) .map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum, temp: e.min_temp_c })) .sort((a, b) => a.t - b.t); bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null; const genug = bvPunkte.length >= 2; bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null; bvDomain = genug ? [...bvVollDomain] : null; // Y-Achse fest auf 10-15V (Nutzerwunsch) statt aus den Punkten berechnet - // deckt den relevanten Bereich ab (12V-Bleibatterie, Ladespannung des // Alternators mit Reserve), ohne die Plausibilitätsgrenze SPANNUNG_MIN_V=8V // (batterie.py) selbst als Achsenskala zu übernehmen. Bleibt dadurch auch // bei jedem Zoom/Pan unverändert, nicht nur "einmalig aus allen Punkten" // wie zuvor. bvYDomain = genug ? [10, 15] : null; const stufen = bvFarbstufen(); // bvPunkte enthält seit dem Filter oben ohnehin nur noch Ruhespannungen // (<= AGM_RUHE_MAX_V) - kein separates bvRuhePunkte mehr nötig. const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null; const soc = letzter ? bvSocProzent(letzter.v) : null; const soh = bvSohBewertung(); const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)"); const leerText = BATTERIEVERLAUF.length === 0 ? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist." : "Noch zu wenige Messwerte für einen Verlauf."; return `
Verlauf
${genug ? `
` : ` ${leerText} `}
${ciSVG(CI.battery12vS, 15)} Ruhespannung
11,6 V · leer 12,2 V · 50 % 12,8 V · voll
Bewertung
Ladezustand (SOC)
${letzter ? `${de(letzter.v, 1)} V · ${soc} % · ${bvSocLabel(soc)}` : `unbekannt`}
Gesundheitszustand (SOH-Trend)
${soh.status ? `${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V` : `-`}
${soh.text}
`; } /* Messwertliste zur Batteriespannungs-Kachel: ein Eintrag pro Tag (dieselben Daten wie das Diagramm), aber als Liste mit Datum, Uhrzeit, Spannung und Außentemperatur. Jede Zeile wischbar zum Löschen wie Fahrten/Tankvorgänge (art "batt" in eintragLoeschen()/LOESCH_TEXT/LOESCH_HINWEIS) - kein data-go/Chevron, es gibt keine Detailseite für einen einzelnen Messwert. Liegt der Tagesminimalwert über AGM_RUHE_MAX_V, wurde er nie in Ruhe gemessen (an dem Tag stand das Fahrzeug nie lange genug still) - die Zeile markiert das mit "Generatorspannung" statt so zu tun, als sei es ein Ruhewert (dieselbe Schwelle wie im Diagramm selbst, siehe bvPunkte oben). */ function vBatterieliste() { const messwerte = BATTERIEVERLAUF.filter((e) => e.min != null).slice().reverse(); if (messwerte.length === 0) { return `
Keine Messwerte
`; } return `
${messwerte.map((e) => { const zeit = e.min_ts ? new Date(e.min_ts) : null; const wann = zeit ? `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : dedat(new Date(e.datum)); return swipeZeile(`batt:${e.datum}`, `
${de(e.min, 2)} V${e.min_temp_c != null ? de(e.min_temp_c, 1) + " °C" : "—"} ${wann}${e.min > AGM_RUHE_MAX_V ? `Generatorspannung` : ""}
`); }).join("")}
`; } function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (bvBreite - BV_ML - BV_MR); } /* Auf [0,1] geklemmter Anteil statt roher Extrapolation - ein Punkt außerhalb von yMin/yMax (z. B. ein Ausreißer, der die Plausibilitätsgrenze auf einer noch nicht aktualisierten Instanz umgangen hat) zeichnet sich sonst weit außerhalb der sichtbaren Fläche und reißt die Linie unkontrolliert nach unten/oben aus - geklemmt liegt er sichtbar am oberen/unteren Rand. */ function bvSkalaY(v, yMin, yMax) { const anteil = Math.max(0, Math.min(1, (v - yMin) / (yMax - yMin))); return BV_H - BV_MB - anteil * (BV_H - BV_MT - BV_MB); } function bvDomainKlemmen(domain) { let [a, b] = domain; const volle = bvVollDomain[1] - bvVollDomain[0]; let breite = Math.min(b - a, volle); breite = Math.max(breite, volle * 0.02); if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; } if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; } return [a, b]; } /* Eine Koordinateneinheit ist seit der dynamischen viewBox genau ein Pixel (siehe bvBreite), die Ränder gelten also unskaliert. */ function bvPlotRahmen(rect) { return { linksPx: rect.left + BV_ML, breitePx: Math.max(1, rect.width - BV_ML - BV_MR) }; } function bvZeichnen() { const svg = $("#bvSvg"); if (!svg || !bvDomain || !bvYDomain) return; // Feste Y-Achse (siehe vBatterieverlauf) statt Neuberechnung aus dem // gerade sichtbaren Zoom-Ausschnitt, damit die Achse beim Zoomen/Verschieben // nicht mehr "streckt". const [yMin, yMax] = bvYDomain; /* Koordinatensystem an die echte Rendergröße koppeln: eine Einheit = ein Pixel. Ohne das zog die feste 320er-viewBox mit preserveAspectRatio="none" auf breiten Spalten alles in die Breite, Achsenzahlen inklusive. Ein ResizeObserver hält das beim Fensterwechsel nach (die viewBox selbst ändert die Elementgröße nicht, deshalb keine Endlosschleife). */ const gemessen = svg.getBoundingClientRect().width; if (gemessen > 0) bvBreite = gemessen; svg.setAttribute("viewBox", `0 0 ${bvBreite.toFixed(1)} ${BV_H}`); if (!svg._bvBeobachtet && typeof ResizeObserver !== "undefined") { svg._bvBeobachtet = true; new ResizeObserver(() => bvZeichnen()).observe(svg); } // etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am // Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt // weiterläuft const spanne = bvDomain[1] - bvDomain[0]; const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05); const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" "); const gitter = [0, 1, 2, 3].map((i) => { const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax); return ` ${de(v, 1)}`; }).join(""); /* Keine Datumsbeschriftung unter dem Diagramm (Nutzerwunsch): der Zeitraum steht als Text unter der Zeichenfläche (#bvRange), das Datum einer einzelnen Messung erscheint beim Antippen im Tooltip. */ const stufen = bvFarbstufen(); const kreise = rahmen.map((p) => { const idx = bvPunkte.indexOf(p); const gewaehlt = idx === bvAusgewaehlt; return ``; }).join(""); /* Sanfte Flaechenfuellung unter der Linie (wie iOS Health/Aktie-Charts) - macht aus der duennen Linie auf der jetzt vollen Spaltenbreite ein "fertiges" Diagramm statt eines leeren Bandes. Rein dekorativ, keine neue Information; niedrige Deckkraft, damit das Gitter durchscheint. */ const flaeche = rahmen.length >= 2 ? `` : ""; svg.innerHTML = ` ${gitter}${flaeche}${kreise}`; const rangeEl = $("#bvRange"); if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`; const tip = $("#bvTooltip"); if (tip) { if (bvAusgewaehlt != null) { const p = bvPunkte[bvAusgewaehlt]; const zeit = new Date(p.minTs); tip.style.display = "block"; /* Der Höchstwert des Tages hat bewusst keinen eigenen Punkt im Diagramm (Nutzerwunsch) - hier ist die Stelle, an der er sichtbar wird. */ const teile = [ `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`, `Min ${de(p.vMin, 2)} V`, ]; if (p.vMax != null) teile.push(`Max ${de(p.vMax, 2)} V`); if (p.temp != null) teile.push(`${de(p.temp, 1)} °C`); tip.textContent = teile.join(" · "); } else { tip.style.display = "none"; } } } function bvNaechsterPunkt(clientX) { const svg = $("#bvSvg"); if (!svg) return null; const rect = svg.getBoundingClientRect(); const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]); let beste = null, besterAbstand = Infinity; bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } }); return beste; } let svAuswahl = 0; function vService() { const sv = CAR.service; return `
${termine().map((t) => { const artIcon = ART_ICON[t.art]; const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : ""; const kopf = `
${iconMarkup}${t.art}
`; // Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne // Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein // frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und // "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag, // zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag // im Servicebuch" - iv war null und der frühe Return griff, bevor fm // überhaupt gelesen wurde. const fm = fahrzeugMeldungRoh(t.art); const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; const hatMeldung = fm.km != null || fmDatum; const iv = intervalle(t); /* Ohne Wartungsplan-Eintrag und ohne Fahrzeugmeldung bleibt bei der Hauptuntersuchung trotzdem ein Termin uebrig: der aus der Erstzulassung abgeleitete (siehe hauptuntersuchungsTermin()). Bis zum 05.09.2026 hat diese Kachel ihn verschluckt und "kein Eintrag im Wartungsplan" gezeigt, waehrend die Uebersicht denselben Termin auswies - gemeldet vom Eigentuemer, zwei Bildschirme, zwei Antworten. */ if (!iv && !hatMeldung && t.datum) return `
${kopf}
${datumText(t.datum, t.genau)}
`; if (!iv && !hatMeldung) return `
${kopf}
kein Eintrag im Wartungsplan
`; const anzeige = iv ? (iv.hersteller || iv.eigen) : null; // Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit // eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim // Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit // dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null // ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) - // dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten. const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null; // Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt // die Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei // Herstellervorgabe unverändert, beim Ölwechsel mit eigenem (kürzerem) // Intervall über oelMeldungsPrognose() auf dieses umgerechnet, statt es // bis zum ersten Servicebucheintrag zu ignorieren (siehe deren // Kommentar). Hochgerechnet wird dabei mit der echten Fahrleistung aus // dem Fahrtenlog statt einer Servicebuch-Rate. const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? (t.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(t.art)) : null; // Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem // Herstellerintervall - die Restkilometerzahl daneben waere nur eine // zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim // eigenen (kuerzeren) Intervall ist sie eine echte Zusatzinformation. const fussnote = hatMeldung && eigenOel ? (CAR.oel.modus === "hersteller" ? `voraussichtlich am ${dedat(eigenOel.datum)}` : `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`) : eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : meldungProg ? (t.art === "Ölwechsel" && CAR.oel.modus !== "hersteller" && meldungProg.restKm != null ? `${de(meldungProg.restKm)} km, voraussichtlich am ${dedat(meldungProg.datum)}` : `voraussichtlich am ${dedat(meldungProg.datum)}`) : ""; const wert = hatMeldung ? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}` : (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)); // Eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm // des Bordcomputers - das kennt das eigene, in "Einrichten" gewaehlte // Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller // als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback // (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht. const herstellervorgabe = hatMeldung || (iv && iv.hersteller); return `
${kopf}
${herstellervorgabe ? `
Herstellervorgabe
` : ""}
${wert}
${fussnote ? `
${fussnote}
` : ""}
`; }).join("")}
Termin vereinbaren ${(() => { const ts = terminAuswahl(), t = gewaehlterTermin(); /* Vorschlag ist das errechnete Faelligkeitsdatum, sonst leer. Der eingetragene Termin wird NICHT gespeichert - siehe der Kommentar an TerminVereinbaren() in der App: er dient allein dem Kalendereintrag ("was, wann, bei welcher Werkstatt") und hat keine weitere Wirkung. */ const vorschlag = t.ziel ? t.ziel.toISOString().slice(0, 10) : ""; return `
`; })()}
Autohaus
${esc(sv.autohaus.name)}
${esc(sv.autohaus.adresse)} ${esc(sv.autohaus.tel)} ${sv.autohaus.mail ? `E-Mail schreiben` : ""} In Karten öffnen
Wartungsplan ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, ``)).join("") : `
Noch keine Einträge

Aus dem Wartungsplan berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.

`}
`; } /* "Daten bearbeiten" auf der Seite Identität und Technik. Der Knopf war bis 2026-08-30 ohne Funktion (kein data-go, kein Handler) - beim Paritätsabgleich aufgefallen und auf Wunsch des Eigentümers jetzt umgesetzt. Bearbeitet werden genau die Angaben, für die es sonst nirgends einen Editor gibt: die Werte des technischen Datenblatts (CAR.technik) und die Ausstattungslisten (CAR.ausstattung). Die Kopfdaten darüber - Modell, Kennzeichen, Ausführung, Erstzulassung, FIN - bleiben bewusst außen vor: die pflegt "Fahrzeug einrichten" in den Einstellungen, und zwei Eingabe- stellen für dasselbe Feld laufen erfahrungsgemäß auseinander. */ let identBearbeitenOffen = false; function vIdent() { if (identBearbeitenOffen) return vIdentBearbeiten(); return `
Fahrzeug
Modell
${esc(CONFIG.fahrzeugtitel)}
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
FIN
${esc(CAR.fin)}
Motor und Leistung
${esc(CAR.details)}
Erstzulassung
${esc(erstzulassungText())}
Kennzeichen
${esc(CONFIG.kennzeichen)}
Zulassung
Deutschland
${CAR.technik.map((g) => `
${esc(g.gruppe)}
${g.posten.map((pp, i) => `
${esc(pp[0])}
${pp[1] ? esc(pp[1]) : "noch offen"}
`).join("")}
`).join("")} ${CAR.ausstattung.map((g) => `
${esc(g.gruppe)}
${g.sonder.map((x, i) => `
${esc(x)}
`).join("")}
`).join("")} `; } /* Bearbeitungsansicht: je Technikgruppe eine Kachel mit einem Eingabefeld je Posten, je Ausstattungsgruppe eine Kachel mit einer Zeile je Position - beide mit "×" zum Entfernen und "+ Position hinzufügen", genau im Zuschnitt der Selbstbeteiligung in vVertragBearbeiten(). Gespeichert wird wie überall sonst sofort je Feld über profilSpeichern(). */ function vIdentBearbeiten() { const technik = CAR.technik || []; const ausstattung = CAR.ausstattung || []; return `
Daten bearbeiten Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen, Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen → Fahrzeug einrichten.
${technik.map((g, gi) => `
${esc(g.gruppe)} ${(g.posten || []).map((pp, i) => `
`).join("")} ${!(g.posten || []).length ? `Keine Angaben` : ""}
`).join("")} ${ausstattung.map((g, gi) => `
${esc(g.gruppe)} ${(g.sonder || []).map((x, i) => `
`).join("")} ${!(g.sonder || []).length ? `Keine Ausstattung hinterlegt` : ""}
`).join("")} `; } function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; } function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; } /* Die Auswahl im Feld "Wofuer": die faelligen Termine plus "Andere". "Andere" ist kein faelliger Termin, sondern ein freier Werkstattbesuch, der zu keiner der drei geplanten Arten gehoert (Reifenwechsel, Garantiearbeit, Nachbesserung). Er hat deshalb weder Ziel noch Kilometerangabe - nur ein Datum, und taucht oben in der Terminliste nicht als Karte auf. */ function terminAuswahl() { return [ ...termine(), { art: "Andere", km: null, ziel: null }, ]; } function gewaehlterTermin() { const auswahl = terminAuswahl(); return auswahl[Math.min(svAuswahl, auswahl.length - 1)]; } /* Die Hauptuntersuchung hat ZWEI Quellen, in dieser Reihenfolge: 1. der letzte Servicebuch-Eintrag plus **24** Monate. 2. die Erstzulassung plus **36** Monate. Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei. Es gibt KEINE dritte Quelle mehr. Bis zum 05.09.2026 stand hier zuerst ein Profilfeld `hauptuntersuchung_faellig`, das alles andere ueberstimmt hat. Dieses Feld hatte in keiner der beiden Oberflaechen ein Eingabefeld und im Backend kein Schema - es lag nur als toter Schluessel in fahrzeugprofil.json und war von dort weder zu sehen noch zu aendern. Gemeldet vom Eigentuemer am 05.09.2026: Wartungsplan leer, Erstzulassung 01.03.2025, angezeigt wurde trotzdem August 2026 - das war jener tote Schluessel ("08/2026"). Das Feld ist samt Lese- und Schreibweg entfernt; die Ableitung ist die einzige Quelle. */ const HU_MONATE_FOLGE = 24; const HU_MONATE_ERSTE = 36; /* Der JUENGSTE Hauptuntersuchungs-Eintrag des Wartungsplans. Nicht ueber letzterEintrag(): das nimmt mit .find() den ersten Treffer in Listenreihenfolge, waehrend die App nach Datum absteigend sortiert. Bei mehreren Eintraegen haetten beide Seiten verschiedene Termine gezeigt, ohne dass man es der Anzeige ansieht. */ function letzteHauptuntersuchung() { let beste = null; let besteZeit = null; for (const e of CAR.service.buch) { if (!e || typeof e.art !== "string" || !e.art.toLowerCase().includes("hauptunter")) continue; const z = datumAusText(e.datum); if (!z) continue; if (besteZeit === null || z.zeit > besteZeit) { beste = e; besteZeit = z.zeit; } } return beste; } function hauptuntersuchungsTermin(bauen) { const eintrag = letzteHauptuntersuchung(); if (eintrag) { return bauen("Hauptuntersuchung", eintrag, null, HU_MONATE_FOLGE); } const erst = datumAusText(CAR.erstzulassung); const termin = bauen("Hauptuntersuchung", null, null, HU_MONATE_ERSTE, erst ? erst.zeit : null); // Eine Erstzulassung als "03/2025" nennt keinen Tag. Dann nennt auch der // abgeleitete Termin keinen - "01.03.2028" waere eine Erfindung. if (erst && erst.genau === "monat") termin.genau = "monat"; return termin; } function termine() { const oel = CAR.oel; const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); const oelM = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); const bauen = (art, basis, kmIntervall, monate, ersatzDatum) => { const kmZiel = basis && kmIntervall ? basis.km + kmIntervall : null; const datZiel = basis ? monatePlus(basis.datum, monate) : (ersatzDatum ? monatePlus(ersatzDatum, monate) : null); const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null; const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit"); return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis, ziel: zuerst === "km" ? kmDatum : datZiel }; }; const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM); // Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose()) // ersetzt hier den km-Anteil aus dem generischen bauen() oben, damit // Übersicht, Service-Kachel und Anstehende Termine überall dieselbe Zahl // für "wann ist der nächste Ölwechsel fällig" zeigen statt drei leicht // unterschiedlicher Werte. const oelProg = oelTermin.basis ? oelwechselPrognose() : null; if (oelProg) { oelTermin.km = oelProg.km; oelTermin.kmDatum = oelProg.datum; oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km"; oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum; } const inspTermin = bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24); // Ganz ohne Servicebucheintrag (kein basis) übernimmt die Fahrzeugmeldung // selbst die Rolle der Prognosequelle - genau wie vService()'s eigenes // meldungProg das schon tut (siehe dessen Kommentar dort). Ohne diesen // Fallback hier zeigte "Übersicht" trotz korrekt gemeldeter Fälligkeit nur // "kein Eintrag im Servicebuch", während "Service" dieselbe Meldung // längst richtig anzeigte - zwei Ansichten, ein Datenstand, zwei // widersprüchliche Aussagen. [oelTermin, inspTermin].forEach((termin) => { if (termin.basis) return; const m = termin.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(termin.art); if (!m) return; termin.km = m.restKm != null && CAR.odoBekannt ? CAR.odo + m.restKm : null; termin.kmDatum = m.datum; termin.zuerst = "km"; termin.ziel = m.datum; }); return [ oelTermin, inspTermin, hauptuntersuchungsTermin(bauen), ]; } /* Liest Name, Adresse, Telefon und E-Mail aus einer vCard (.vcf) - deckt vCard 3.0/4.0 so weit ab, wie ein typischer Werkstatt-Kontakt aus dem Adressbuch (iOS/Android/Outlook) sie befüllt. Kein vollständiger Parser, nur die vier Felder, die das Autohaus-Formular kennt. Gefaltete Zeilen (Fortsetzung mit führendem Leerzeichen/Tab, RFC 6350 §3.2) werden vor dem zeilenweisen Parsen entfaltet; ADR setzt sich aus PO-Box, Zusatz, Straße, Ort, Region, PLZ, Land zusammen (in dieser Reihenfolge, mit Semikolon getrennt) - hier zu einer lesbaren Ein-Zeilen-Adresse zusammengefasst, weil das Formularfeld nur eine Zeile kennt. Mehrfache TEL/EMAIL/ADR-Zeilen: die erste gewinnt. */ function vcardParsen(text) { const entfaltet = String(text).replace(/\r\n/g, "\n").replace(/\n[ \t]/g, ""); const zeilen = entfaltet.split("\n").map((z) => z.trim()).filter(Boolean); const entwerten = (s) => s.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\"); let name = null, org = null, tel = null, mail = null, adresse = null; for (const zeile of zeilen) { const doppelpunkt = zeile.indexOf(":"); if (doppelpunkt < 0) continue; const eigenschaft = zeile.slice(0, doppelpunkt).split(";")[0].toUpperCase(); const wert = zeile.slice(doppelpunkt + 1); if (eigenschaft === "FN" && !name) name = entwerten(wert); else if (eigenschaft === "ORG" && !org) org = entwerten(wert.split(";")[0]); else if (eigenschaft === "TEL" && !tel) tel = entwerten(wert); else if (eigenschaft === "EMAIL" && !mail) mail = entwerten(wert); else if (eigenschaft === "ADR" && !adresse) { const teile = wert.split(";").map(entwerten); const strasse = [teile[1], teile[2]].filter(Boolean).join(" "); const ort = [teile[5], teile[3]].filter(Boolean).join(" "); adresse = [strasse, ort, teile[6]].filter(Boolean).join(", ") || null; } } if (!name && !org && !tel && !mail && !adresse) { throw new Error("Keine lesbaren Kontaktfelder gefunden - ist das eine .vcf-Datei?"); } return { name: name || org || null, adresse, tel, mail }; } function vWerkstatt() { const a = CAR.service.autohaus; return `
Angaben zum Autohaus
`; } function vSbuch() { const i = parseInt(route.id, 10), e = CAR.service.buch[i]; if (!e) return `
Eintrag nicht gefunden
`; const istEntwurf = entwurfIndex === i; const artBekannt = SERVICE_ARTEN.includes(e.art); return `
${esc(e.art)}
${de(e.km)}km
${e.datum} · ${esc(e.werkstatt)}
Eintrag
km
Durchgeführte Arbeiten
${istEntwurf ? `` : ``}`; } function vVers() { const v = CAR.versicherung; return `
Versicherung - Schaden Notruf 24/7 Inland · ${esc(v.service)} Ausland · ${esc(v.notfall)}
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))}
${eur(v.beitrag)}
${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer${(v.steuersatz || "").trim() ? ` (${esc(v.steuersatz)})` : ""}
${v.teile.map((t, i) => `
${esc(t.bez)}${t.sf ? `
${esc(t.sf)} · Beitragssatz ${esc(t.satz)}
` : ""}
${eur(t.betrag)} €${v.beitrag ? de(t.betrag / v.beitrag * 100, 0) : 0} %
`).join("")}
${v.teile.map((t, i) => ``).join("")}
Vertrag
Gesellschaft
${esc(v.gesellschaft)}
Umfang
${esc(v.vertrag)}
Vertragsnummer
${esc(v.nummer)}
${v.selbst.map(([b, w]) => `
Selbstbeteiligung ${esc(b)}
${esc(w)}
`).join("")}
Schadenfreiheitsklasse
${v.teile.map((t) => t.sf).filter(Boolean).join(" / ")}zuvor ${v.teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}
${vSteuer()}`; } function vNotrufBearbeiten() { const v = CAR.versicherung; return `
Rufnummern ändern
`; } function vBeitrag() { const v = CAR.versicherung; return `
Beitrag anpassen
Der Jahresbeitrag oben ist die Summe dieser drei Posten und rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel
`; } function vVertragsdetails() { const v = CAR.versicherung; return ` ${v.leistungen.filter((g) => (g.gruppe || "").trim() || (g.posten || []).length).map((g) => `
${esc(g.gruppe)}
${g.posten.map((x, i) => `
${esc(x)}
`).join("")}
`).join("")}`; } function vVertragBearbeiten() { const v = CAR.versicherung; return `
Vertrag
Selbstbeteiligung ${v.selbst.map(([b, w], i) => `
`).join("")} ${!v.selbst.length ? `Keine Selbstbeteiligung hinterlegt` : ""}
Schadenfreiheitsklasse · zuvor
Die aktuelle Schadenfreiheitsklasse änderst du unter "Beitrag anpassen" — hier steht nur der vorherige Wert für den Vergleich.
`; } function vSchutz() { const v = CAR.versicherung, sb = v.schutzbrief; return `
Die Entscheidung am Schadenort
Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die anderen beiden.
${sb.entweder.map((x, i) => `
${esc(x.bez)}
${esc(x.grenze)}${x.zusatz ? `${esc(x.zusatz)}` : ""}
`).join("")}
Mobilität wird binnen 60 Minuten organisiert
Wann der Schutzbrief greift
${sb.ausloeser.map((a, i) => `
${esc(a)}
`).join("")}
Gilt in ${esc(sb.gilt)} Versichert: ${esc(sb.versichert)}
${sb.gruppen.map((g) => `
${esc(g.titel)}
${g.posten.map((pp, i) => `
${esc(pp[0])}${pp[2] ? `
${esc(pp[2])}` : ""}
${esc(pp[1])}
`).join("")}
`).join("")}
Nicht versichert
${sb.nicht.map((n, i) => `
${esc(n[0])}
${esc(n[1])}
`).join("")}
Deine Pflichten
${sb.pflichten.map((x, i) => `
${esc(x)}
`).join("")}
${esc(sb.rang)}
${esc(sb.stand)}
`; } function vSteuer() { const t = CAR.steuer; return `
Kfz-Steuer
Betrag
${eur(t.betrag)} €${t.zeitraum === "jährlich" ? "im Jahr" : esc(t.zeitraum)}
Nächste Fälligkeit
${t.faellig ? dedat(new Date(t.faellig)) : "unbekannt"}
`; } function vSteuerBearbeiten() { const t = CAR.steuer; return `
Kfz-Steuer
`; } /* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau der Menüleiste wieder angewendet. */ const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift"; let beschriftung = (() => { try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; } })(); function csvDatei(name, kopf, zeilen) { const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n"); const a = document.createElement("a"); a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt); a.download = name; document.body.appendChild(a); a.click(); a.remove(); } /* Wann das Geraet einen Wert zuletzt bestaetigt hat - oder dass es das nie getan hat. Steht dort, wo frueher nichts stand, wenn kein Urteil moeglich ist: ein stiller Gleichstand mit einem veralteten Wert ist schlimmer als gar kein Vergleich (04.09.2026). */ function dongleStand(w) { if (!w.gemeldet_am) return "nie bestätigt"; const d = new Date(w.gemeldet_am); if (!gueltigesDatum(d)) return "nie bestätigt"; const tage = Math.floor((Date.now() - d.getTime()) / 86400000); const wann = dedat(d); return tage >= 1 ? `bestätigt ${wann} (${tage} T. alt)` : `bestätigt ${wann}`; } function vEinst() { return `
Fahrzeug einrichten ${einrichtenOffen ? `
automatisch
Ölwechsel-Intervall
${CAR.oel.modus === "hersteller" ? `
Laufleistung
${de(CAR.oel.herstellerKm)} km
Zeit
${CAR.oel.herstellerMonate} Monate
` : `
`} Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum nachträglich an — aus dem Verlauf, den Home Assistant zu den oben zugeordneten Sensoren bereits aufgezeichnet hat. ` : ""}
${(() => { /* Der Regler schreibt NICHT mehr beim Loslassen. Sein Wert stellt zugleich den Schlaf-Timeout des Dongles - eine Änderung am Fahrzeug, die über zwei Netze geht; die soll bewusst ausgelöst werden und nicht als Nebenwirkung einer Wischgeste entstehen (Vorgabe des Eigentümers, 04.09.2026). Der Knopf erscheint erst, wenn der Regler wirklich woanders steht - ein dauerhaft sichtbarer, meist wirkungsloser Knopf wäre Beiwerk. */ const minuten = PAUSENZEIT_ENTWURF ?? CONFIG.pausenzeit; const geaendert = PAUSENZEIT_ENTWURF !== null && PAUSENZEIT_ENTWURF !== CONFIG.pausenzeit; return `
Fahrt beenden
${minuten} Min.
${geaendert ? `
` : ""} Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, dass es gleich weitergeht. Danach schläft er — das spart Strom, kostet beim Fortsetzen aber ein paar Sekunden fürs Aufwachen.
`; })()}
Darstellung
Aus zeigt nur die Symbole und gibt etwas Höhe frei
Bild der Übersicht
Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen. ${(() => { const info = bildInfo(startIndex()); return `
${/* "klein" = das Bild bestimmt die Hoehe, statt im 16:9-Kasten beschnitten zu werden (siehe .carfix.klein). Wer hier prueft, ob das richtige Foto hochgeladen ist, muss es ganz sehen. */"" }${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein", null, true)}
`; })()}
Kraftstoff-Rabatt
${CAR.smartdeal.aktiv ? `
${dedat(new Date(CAR.smartdeal.laeuftAb))}
` : `Ersparnis und Bildmarke werden ausgeblendet`} ${smartdealPopupOffen ? `
` : ""}
Fahrzeugprofil Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, Ausstattung, Räder, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet.
Backup Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen Gesamtstand als einzelne Datei. ${CONFIG.letztesBackup ? `Letztes Backup: ${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""}
Version
Installiert
${esc(APP_VERSION || "unbekannt")}Home-Assistant-Integration
${(() => { const u = INTEGRATION_UPDATE; const laeuft = INTEGRATION_UPDATE_LAEUFT; // Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App // Control blockiert dessen Ausführung zuverlässig) - install.ps1 // bleibt nur noch für die Erstinstallation nötig, bevor die // Integration überhaupt läuft und sich selbst aktualisieren kann. // // HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt // lang, ohne verlässliche Fortschrittsangabe - ein laufender // Indikator statt eines stumm daliegenden Knopfes verhindert, dass // das wie ein Hänger aussieht (derselbe Grund wie beim // Erstladen-Spinner, .lade-spinner). Nach Erfolg führt der Knopf // direkt zum nächsten nötigen Schritt (Neustart), statt den Nutzer // selbst suchen zu lassen. const spinner = (text) => `
${esc(text)}
`; if (laeuft === "installieren") return spinner("Installiere …"); if (laeuft === "neustart") return spinner("Home Assistant startet neu …"); if (u && u.installiert) { return ` Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart geladen. `; } if (laeuft === "pruefen") return spinner("Prüfe …"); if (u && u.verfuegbar) { /* Zeitstempel und ein zweiter Pruefknopf gehoeren hierher, nicht nur in den Zweig "ist aktuell": die angezeigte Version ist das gespeicherte Ergebnis der letzten Pruefung, nicht der Live-Stand. Ohne beides sah eine Wochen alte Zahl aus wie eine Tatsache, und es gab keinen Weg, sie aufzufrischen, solange ueberhaupt ein Update angeboten wurde. */ return `
Verfügbare Version
${esc(u.version)}${u.geprueft_am ? `geprüft ${dezeitpunkt(new Date(u.geprueft_am))}` : ""}
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners. `; } return ` ${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."} `; })()} ${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler && !INTEGRATION_UPDATE_LAEUFT ? `${esc(INTEGRATION_UPDATE.fehler)}` : ""}
${debugOffen ? ` ${ZUGAENGE.length ? `
Zugänge ${ZUGAENGE.map((z, i) => `
${esc(z.zweck)}
${z.gesetzt ? `` : ""}
`).join("")} Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist. Dieselben Felder gibt es unter Einstellungen → Geräte & Dienste → Audi Dashboard → Konfigurieren.
` : ""} ${DONGLE && DONGLE.aktiv ? `
Dongle ${DONGLE.stand && DONGLE.stand.fehler ? `${esc(DONGLE.stand.fehler)}` : ""} ${DONGLE.stand && (DONGLE.stand.werte || []).length ? `
${DONGLE.stand.werte.map((w) => `
${esc(w.titel)} ${esc(w.nummer)}
${ w.geraet === null || w.geraet === undefined ? "–" : esc(String(w.geraet)) }${w.einheit ? " " + esc(w.einheit) : ""}${ w.offen !== null && w.offen !== undefined ? ` → ${esc(String(w.offen))}, unterwegs` : w.abweichung === true ? ` (wir: ${esc(String(w.unser))})` : ` ${dongleStand(w)}` }
`).join("")}
` : ""} ${DONGLE.stand && DONGLE.stand.gelesen_am // Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende // dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen. ? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. ` : "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als „unterwegs" daneben.
` : ""} ${(() => { /* Der letzte Datensatz, wie er in Home Assistant angekommen ist - siehe geraet.py. Der Abstand zwischen Gerätezeit und Ankunft ist die Angabe, die bei gepufferten Datensätzen zählt: genau dieser Rückstand war die Wurzel des 3-km-Fahrt-Fehlers. */ const d = LETZTER_DATENSATZ; if (!d) return `
Letzter Datensatz Noch nichts gemeldet. Dafür braucht es die Meldezeit oder das Fahrtsignal im Setup - und mindestens einen Datensatz des Geräts, seit Home Assistant läuft.
`; const rueckstand = d.meldezeit && d.angekommen ? Math.round((Date.parse(d.angekommen) - Date.parse(d.meldezeit)) / 1000) : null; return `
Letzter Datensatz
Gerät
${esc(d.geraet || "unbekannt")}
Angekommen
${dezeitpunkt(new Date(d.angekommen))}
Gerätezeit
${d.meldezeit ? dezeitpunkt(new Date(d.meldezeit)) : "–"}${ rueckstand !== null ? `Rückstand ${de(rueckstand)} s` : "" }
${d.werte.map((w) => `
${esc(w.name)}
${esc(String(w.wert))}${ w.einheit ? " " + esc(w.einheit) : "" }
`).join("")}
${de(d.gesamt)} Werte aus derselben Meldung. Was das Gerät in diesem Datensatz nicht geschickt hat, steht hier nicht - es behält in Home Assistant seinen alten Wert. Nach einem Neustart ist „Angekommen" der Zeitpunkt des Neustarts, nicht der einer Übertragung — der Rückstand sagt dann, wie lange das Gerät schon schweigt.
`; })()} ` : ""}`; } let nmOffen = false; let einrichtenOffen = false; let debugOffen = false; /* Entwurf des Reglers "Fahrt beenden" (Minuten) - null heisst: nicht angefasst. Er liegt bewusst NEBEN CONFIG.pausenzeit und nicht darin: erst der Knopf "Speichern" uebernimmt ihn, weil derselbe Wert den Schlaf-Timeout des Dongles stellt. Siehe die Kachel in vEinst(). */ let PAUSENZEIT_ENTWURF = null; /* Das Blatt zu einem vorhandenen Foto: ersetzen oder loeschen. Loest bildMenuOffen ab - der Zustand wird nicht mehr gebraucht, weil das Blatt selbst der Zustand ist (siehe aktionsblatt()). */ function bildAktionsblatt(dateiname, name) { aktionsblatt(name, [ { text: "Foto ersetzen", tun: () => { const input = ROOT.querySelector(`[data-bildupload="${dateiname}"]`); if (input) input.click(); } }, { text: "Foto löschen", destruktiv: true, tun: () => { bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { (async () => { try { await HASS.callService(DOMAIN, "bild_loeschen", { dateiname }); } catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } bildVersion = Date.now(); render(); })(); }); } }, ]); } let smartdealPopupOffen = false; // "sommer"|"winter", welcher Reifensatz gerade sein km-Korrektur-Feld offen // hat (Editier-Symbol neben der km-Anzeige) - null wenn keins offen ist. let kmOffen = null; /* --------------------------------------------------------- Setup-Popup Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten -> Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern" an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */ let setupOffen = false; let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder let setupZuordnungStart = null; // Schnappschuss beim Öffnen - erlaubt "Abbrechen" ohne Nebeneffekt let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null let setupSuchtext = ""; let setupSpeichertLaeuft = false; let ALLE_ENTITAETEN = []; function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; } function alleEntitaeten() { if (!HASS || !HASS.states) return []; return Object.keys(HASS.states).map((id) => { const attrs = (HASS.states[id] && HASS.states[id].attributes) || {}; return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" }; }); } function entitaetName(id) { if (!id) return ""; const treffer = ALLE_ENTITAETEN.find((e) => e.id === id); return treffer ? treffer.name : id; } /* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten, Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart, siehe entitaetKandidaten()). */ function entitaetScore(feld, e, suchtext, idx) { let score = 0; const hay = (e.id + " " + e.name).toLowerCase(); for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5; if (feld.domains && feld.domains.includes(e.domain)) score += 3; if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3; if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2; // Der Signalname aus dem Katalog ("z. B. can_fuel_volume") ist der // treffsicherste Hinweis, den es gibt - deutlich mehr wert als ein // einzelnes Stichwort. // Bei Listenfeldern der Name der jeweiligen Position, nicht der der ersten - // sonst stuende in allen vier Zeilen dieselbe Entitaet oben. const beispiel = feldBeispiel(feld, idx); if (beispiel && hay.includes(beispiel.toLowerCase())) score += 8; if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10; return score; } function entitaetKandidaten(feldKey, suchtext, aktuellerWert, idx) { const feld = setupKatalog().find((f) => f.key === feldKey); if (!feld) return []; const text = (suchtext || "").trim().toLowerCase(); let liste = ALLE_ENTITAETEN.filter((e) => { if (e.id === aktuellerWert) return true; if (setupNurPassend) { if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false; if (feld.units && feld.units.length) { if (!feld.units.includes(e.unit)) return false; } else if (feld.device_classes && (feld.device_classes.includes("date") || feld.device_classes.includes("timestamp"))) { // Kein Einheiten-Feld, aber "Datum" ist der angezeigte Output // (feldEinheitAnzeige()) - dieselbe Bedingung hier gespiegelt, damit // "Nur passende Sensoren anzeigen" wirklich nur Sensoren mit // passendem Output zeigt, nicht jeden beliebigen sensor.*. if (e.deviceClass !== "date" && e.deviceClass !== "timestamp") return false; } } if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false; return true; }); /* "Nur passende Sensoren" sucht zusaetzlich nach dem Namen, der neben der Ueberschrift als "(z. B. ...)" steht - dem tatsaechlichen Signalnamen der Integration, nicht einem ausgedachten Beispiel (siehe FELDER in einstellungen.py). Domain und Einheit allein lassen bei einem Fahrzeug mit siebzig Sensoren immer noch Dutzende uebrig; der Signalname trifft genau einen. Mit Rueckfall: findet der Name nichts, bleibt die ungefilterte Liste stehen. Eine leere Auswahl waere schlimmer als eine lange - der Beispiel- name stammt aus dieser einen Installation, eine andere kann ihre Signale anders nennen. */ if (setupNurPassend && feld.beispiel) { const beispiel = feld.beispiel.toLowerCase(); const genau = liste.filter( (e) => e.id === aktuellerWert || e.id.toLowerCase().includes(beispiel) || e.name.toLowerCase().includes(beispiel.replace(/_/g, " ")), ); if (genau.length) liste = genau; } liste.sort((a, b) => entitaetScore(feld, b, suchtext, idx) - entitaetScore(feld, a, suchtext, idx)); return liste.slice(0, 60); } /* Ab diesem Wert gilt ein Vorschlag als belastbar. entitaetScore() vergibt für einen reinen Domain-Treffer bereits 3 Punkte - eine Schwelle von 3 wäre deshalb immer erfüllt und hätte jedes leere Feld mit dem erstbesten Sensor der passenden Domain belegt (Reihenfolge aus Object.keys(HASS.states), also willkürlich). 5 verlangt mindestens einen Stichworttreffer. */ const SETUP_VORSCHLAG_SCHWELLE = 5; /* Bester Vorschlag für ein Feld, der noch nicht anderweitig vergeben ist. Das "noch nicht vergeben" ist der Punkt: ohne diese Prüfung bekamen alle vier Positionen einer Liste (Türen, Fenster, Schlösser) denselben Sensor, weil der Vorschlag nicht vom Index abhängt. Der Sicherheitscheck hätte danach viermal dieselbe Tür geprüft und "Sicher abgestellt" gemeldet, obwohl drei Türen nie geprüft wurden. */ function setupVorschlag(feld, schonVergeben) { for (const kandidat of entitaetKandidaten(feld.key, "", "")) { if (schonVergeben.has(kandidat.id)) continue; if (entitaetScore(feld, kandidat, "") < SETUP_VORSCHLAG_SCHWELLE) return ""; return kandidat.id; } return ""; } function setupOeffnen() { if (!setupBereit()) return; ALLE_ENTITAETEN = alleEntitaeten(); setupNurPassend = true; const werte = (ENTITAETEN && ENTITAETEN.werte) || {}; const zuordnung = {}; /* Bereits gesetzte und bereits vorgeschlagene IDs, damit kein Sensor zweimal landet. Vorbelegte Werte kommen zuerst hinein. */ const vergeben = new Set(); for (const feld of setupKatalog()) { const vorhandeneWerte = feld.liste ? (werte[feld.key] || []) : [werte[feld.key]]; for (const id of vorhandeneWerte) if (id) vergeben.add(id); } for (const feld of setupKatalog()) { if (feld.liste) { zuordnung[feld.key] = feld.positionen.map((_, i) => { const vorhanden = (werte[feld.key] || [])[i]; if (vorhanden) return vorhanden; const vorschlag = setupVorschlag(feld, vergeben); if (vorschlag) vergeben.add(vorschlag); return vorschlag; }); } else { const vorhanden = werte[feld.key]; if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; } const vorschlag = setupVorschlag(feld, vergeben); if (vorschlag) vergeben.add(vorschlag); zuordnung[feld.key] = vorschlag; } } setupZuordnung = zuordnung; setupZuordnungStart = JSON.parse(JSON.stringify(zuordnung)); setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false; setupOffen = true; render(); } /* Das Setup lässt sich erst öffnen, wenn der Katalog wirklich da ist. Ohne diese Prüfung öffnete sich das Fenster leer (setupKatalog() liefert dann []), und ein Klick auf "Speichern" schrieb {} - was entitaeten.json vollständig ersetzt und damit ALLE Zuordnungen löscht. Weil die Werte im laufenden Prozess erhalten bleiben, wäre das erst beim nächsten Neustart aufgefallen. */ function setupBereit() { if (setupKatalog().length) return true; hinweis("Setup noch nicht bereit", "Die Liste der Sensor-Rollen wurde noch nicht vom Server geladen. Nach einem Neustart dauert das einen Moment - bitte gleich noch einmal versuchen."); return false; } function setupSchliessen() { setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; render(); } /* Passt die zugeordnete Entität zum Beispielnamen der Rolle? Der Beispielname steht im Feld-Katalog (FELDER in einstellungen.py, Schlüssel "beispiel") und ist der treffsicherste Hinweis, den es gibt - entitaetScore() gewichtet ihn deshalb mit 8, mehr als jedes Stichwort. Dieselbe Prüfung hier noch einmal, nur fürs Auge: wer eine Rolle richtig belegt hat, soll das sehen, ohne den langen Entity-Namen mit dem Beispiel daneben Zeichen für Zeichen zu vergleichen. Bewusst nur ein Hinweis, keine Bedingung: es gibt Installationen mit anderer Datenquelle, deren Namen nicht passen und die trotzdem richtig zugeordnet sind. Deshalb grün als Bestätigung, aber kein Rot und keine Warnung, wenn es fehlt. */ function entitaetPasstZumBeispiel(key, idx, wert) { if (!wert) return false; const beispiel = feldBeispiel(setupKatalog().find((f) => f.key === key), idx); if (!beispiel) return false; return String(wert).toLowerCase().includes(String(beispiel).toLowerCase()); } /* Der Beispielname für eine bestimmte Position einer Rolle. Listenfelder (Tür- und Fenstersensoren, vier Positionen) haben je Position einen eigenen Namen: die zweite Position kann "front_left_door" nie enthalten. Ohne diese Unterscheidung stand nur bei der ersten Position ein Erwartungswert, und nur sie konnte grün werden - vom Eigentümer beanstandet am 01.09.2026. */ function feldBeispiel(feld, idx) { if (!feld) return null; if (idx != null && Array.isArray(feld.beispiele) && feld.beispiele[idx]) { return feld.beispiele[idx]; } return feld.beispiel || null; } /* Der Klammertext "(z. B. …)" - im Kopf einer Rolle wie an einer Listenposition derselbe Bau, damit beide gleich aussehen. */ function beispielHinweis(name) { return name ? `(z. B. ${esc(name)})` : ""; } /* Entity-IDs, die im Arbeitsstand mehr als einer Rolle zugeordnet sind. */ function setupDuplikate() { const zaehler = {}; for (const feld of setupKatalog()) { const werte = feld.liste ? (setupZuordnung[feld.key] || []) : [setupZuordnung[feld.key]]; for (const id of werte) { if (id) zaehler[id] = (zaehler[id] || 0) + 1; } } return Object.keys(zaehler).filter((id) => zaehler[id] > 1); } /* Hier stand bis 2026-08-23 setupGeaenderteTriggerFelder(): drei Felder (Zündung, Kilometerstand, Tankfüllstand) waren in der pyscript-Fassung beim Modul-Laden fest in die Trigger-Registrierung eingebacken und wirkten erst nach einem Neustart von Home Assistant. Das Setup-Fenster musste deshalb erkennen, ob eines davon geändert wurde, davor warnen und einen Neustart-Knopf anbieten. Die Integration bindet ihre Zustandsbeobachter bei jedem Speichern neu (trigger_neu_binden in koordinator.py). Die Warnung, der Knopf und der Dienst dahinter sind damit ersatzlos entfallen - Speichern wirkt jetzt bei allen Feldern sofort. */ function setupSpeichernAusfuehren() { // Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf // irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben() // ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles. if (!setupZuordnung || !Object.keys(setupZuordnung).length) { hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen."); return; } setupSpeichertLaeuft = true; render(); (async () => { try { await HASS.callService(DOMAIN, "entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); } catch (err) { setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return; } setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render(); })(); } /* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in der App zu bemerken. */ function entitaetStatusWarnung(wert) { if (!wert) return ""; const zustand = HASS.states[wert] ? HASS.states[wert].state : null; if (zustand === null) return `
Entität nicht gefunden.
`; if (zustand === "unavailable" || zustand === "unknown") return `
Aktuell „${esc(zustand)}" - kein Wert vom Sensor.
`; return ""; } function vReifen() { const r = CAR.reifen; const feld = (k, schluessel, bez, typ) => { const t = r[k]; return `
`; }; const satz = (k) => { const t = r[k], aktiv = r.aktiv.toLowerCase() === k; const radDatei = `rad-${k}.webp`; const radLabel = k === "sommer" ? "Sommerrad" : "Winterrad"; const satzLabel = k === "sommer" ? "Sommerräder" : "Winterräder"; return `
${satzLabel}
${t.km != null ? de(t.km) : "–"}km
${bildMitPlatzhalter(bildAdresse(radDatei), radDatei, radLabel, "radbild")}
${kmOffen === k ? `
km
Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem Satz zählen ab hier weiter dazu.
` : ""}
${feld(k, "marke", "Hersteller")} ${feld(k, "modell", "Modell")} ${feld(k, "mass", "Größe")} ${feld(k, "dot", "DOT")}
/ bar
`; }; return `
Anzugsmoment der Radschrauben
${de(r.anzugsmoment)}Nm
${nmOffen ? `
Nm
` : ""}
Wechseltermin
${satz("sommer")} ${satz("winter")} ${vReifenArchiv()}`; } /* Übersicht der per "Neue Räder anlegen" archivierten Sätze - jede Zeile aufklappbar (dasselbe acc-h/acc-b-Muster wie die Tankvorgänge-Liste), mit inline editierbaren Feldern wie bei den laufenden Sätzen oben, nur über eigene Dienste statt profilSpeichern (siehe profilZuCar()). */ function vReifenArchiv() { const archiv = CAR.reifen.archiv || []; if (archiv.length === 0) return ""; // Kein .mitEinheit hier (das erzwingt eine feste 96px-Eingabebreite fürs // Zahl+Einheit-Muster, z. B. km/Nm - für freien Text wie Hersteller/Modell/ // Kommentar riss das den Wert ab, siehe live beobachtet: "Continenta", // "SportConta"). Genau wie feld() in satz() oben: einfaches , das // sich über den .feld-Flex den ganzen Rest der Zeile nimmt. const feld = (a, schluessel, bez) => `
`; return `
Archiv ${archiv.map((a, i) => { const satzLabel = a.satz === "winter" ? "Winterräder" : "Sommerräder"; /* Nach links wischen zum Löschen, wie in der Fahrten- und Tankvorgangsliste. Der Löschknopf in der aufgeklappten Zeile bleibt: am Rechner ist er der kürzere Weg. Umschlossen ist nur die Kopfzeile, nicht der aufgeklappte Rumpf - sonst verschöbe ein waagerechtes Ziehen in einem Eingabefeld die ganze Zeile. */ return swipeZeile(`rfarch:${a.id}`, ``) + `
km
${feld(a, "marke", "Hersteller")} ${feld(a, "modell", "Modell")} ${feld(a, "mass", "Größe")} ${feld(a, "dot", "DOT")}
/ bar
`; }).join("")}
`; } /* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander- laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch, Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet das Backend ab. */ /* Gespeichert bleibt der kleingeschriebene Schlüssel - er wird an mehreren Stellen verglichen (Pille auf der Einzelfahrt, Statistik "Arbeitsweg", CSV) und steht so auch in allen bereits erfassten Fahrten. Angezeigt wird er groß, deshalb artText() statt eines geänderten Werts. */ const FAHRT_ARTEN = ["privat", "arbeitsweg"]; const artText = (a) => (a ? a.charAt(0).toUpperCase() + a.slice(1) : ""); /* "1 h 15 min" statt "75 min" - bei mehrstündigen Fahrten liest sich die reine Minutenzahl schlecht. Gleiche Schreibweise wie companion-app/src/ format.ts' dauer() (Paritätsregel), zweistellig gepolsterte Minuten. */ function dauerText(sekunden) { if (sekunden == null || !isFinite(sekunden)) return "–"; const min = Math.max(0, Math.round(sekunden / 60)); const std = Math.floor(min / 60); // "Min." statt "min": deutsche Abkuerzung, wie sie die Oberflaeche sonst // auch verwendet ("Geparkt seit 2 Tg. 16 Std. 12 Min."). return std ? `${std} h ${String(min % 60).padStart(2, "0")} Min.` : `${min} Min.`; } /* Start und Ankunft eines Formulars als echte Zeitpunkte. Liegt die Ankunft vor der Abfahrt, wurde über Mitternacht gefahren - dann zählt der Folgetag. null, solange Datum oder eine der beiden Uhrzeiten fehlt. */ function fahrtZeitraum(praefix) { const datum = $(`#${praefix}Datum`).value, ab = $(`#${praefix}Start`).value, an = $(`#${praefix}Ende`).value; if (!datum || !ab || !an) return null; const start = new Date(`${datum}T${ab}:00`); let ende = new Date(`${datum}T${an}:00`); if (isNaN(start) || isNaN(ende)) return null; if (ende <= start) ende = new Date(ende.getTime() + dTag); return { start, ende }; } function fahrtDauerText(praefix) { const z = fahrtZeitraum(praefix); return z ? dauerText((z.ende - z.start) / 1000) : "–"; } /* Datum für ein in Ortszeit. Bewusst nicht über toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht den Vortag. */ function datumsfeld(d) { const zwei = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`; } function zeitfeld(d) { const zwei = (n) => String(n).padStart(2, "0"); return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`; } function fahrtFelder(praefix, t) { const start = t && t.ts_start ? new Date(t.ts_start) : new Date(); const ende = t && t.ts_end ? new Date(t.ts_end) : null; const art = t && t.art ? t.art : FAHRT_ARTEN[0]; const wert = (v) => (v == null ? "" : v); const startzeit = t ? zeitfeld(start) : "08:00", ankunftzeit = ende ? zeitfeld(ende) : "08:30"; // Die Dauer ist bewusst kein Eingabefeld: sie ergibt sich aus Start und // Ankunft und würde sich sonst mit ihnen widersprechen können. Aus // t.duration_s statt neu berechnet, damit eine über Mitternacht laufende // bestehende Fahrt (Ankunft "kleiner" als Start) korrekt bleibt; bei einer // neuen Fahrt (t === null) ergibt sich der Startwert aus denselben // Vorgabezeiten wie die Felder selbst. Der data-fahrtzeitfeld-Handler // rechnet bei jeder Änderung neu. let dauerSekunden = t && t.duration_s != null ? t.duration_s : null; if (dauerSekunden == null) { const [sh, sm] = startzeit.split(":").map(Number), [eh, em] = ankunftzeit.split(":").map(Number); dauerSekunden = ((eh * 60 + em) - (sh * 60 + sm) + (eh * 60 + em <= sh * 60 + sm ? 1440 : 0)) * 60; } const dauer = dauerText(dauerSekunden); return `
${dauer}
km
`; } function fahrtFormularWerte(praefix) { const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); }; const text = (id) => $(`#${praefix}${id}`).value.trim() || null; const z = fahrtZeitraum(praefix); return { ts_start: z ? z.start.toISOString() : null, ts_end: z ? z.ende.toISOString() : null, art: $(`#${praefix}Art`).value, start_ort: text("Startort"), ziel_ort: text("Zielort"), odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"), }; } function vTripsFormular() { // eigene Funktion, damit sowohl der volle als auch der Leer-Zustand // (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe // Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf // wechselte zu "Abbrechen", aber es erschienen keine Felder. return `${fahrtOffen ? `
Neue Fahrt ${fahrtFelder("neuFahrt", null)}
` : ""} `; } /* Jahre laufen absteigend, Monate und Zeilen liefen bis zur Designpruefung 2026-08-30 aufsteigend - in derselben Liste zwei Richtungen, und die neueste Fahrt stand ganz unten, ausserhalb des sichtbaren Bereichs. neuesteZuerst() dreht Zeilen und Monatsreihenfolge um; der zuerst aufgeklappte Monat (mi === 0) ist damit automatisch der neueste. */ function neuesteZuerst(liste, feld) { return liste.slice().sort((a, b) => new Date(b[feld]) - new Date(a[feld])); } function vTrips() { const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a); /* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt, dass der Motor läuft. */ if (!jahre.length) return `
Noch keine Fahrten

Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.

${vTripsFormular()}`; let h = `
`; jahre.forEach((j, ji) => { const tj = neuesteZuerst(TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j), "ts_start"); h += `
`; const monate = [...new Set(tj.map((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" })))]; monate.forEach((m, mi) => { const tm = tj.filter((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" }) === m); h += `
`; tm.forEach((t) => { h += swipeZeile(`trip:${t.trip_id}`, ``); }); h += `
`; }); h += `
`; }); return h + `
${vTripsFormular()}`; } function vTrip(id) { const t = TRIPS.find((x) => x.trip_id === id); if (!t) return `
Fahrt nicht gefunden
`; if (fahrtBearbeitenOffen) { return `
Fahrt bearbeiten ${fahrtFelder("bearbFahrt", t)}
`; } return ` ${mapBox()}
Distanz
${(streckeTeile(t.distance_km) || { wert: "–", einheit: "km" }).wert}${(streckeTeile(t.distance_km) || { einheit: "km" }).einheit}
Start
${dezeitpunkt(new Date(t.ts_start))}${fahrtOrtText(t.start_address, t.start_lat, t.start_lon)}
Ziel
${dezeitpunkt(new Date(t.ts_end))}${fahrtOrtText(t.end_address, t.end_lat, t.end_lon)}
Startkilometer
${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}
Endkilometer
${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}
Dauer
${dauerText(t.duration_s)}
Ø Geschwindigkeit
${t.avg_speed_kmh != null ? de(t.avg_speed_kmh, 1) + " km/h" : "liegt nicht vor"}
Höchstgeschwindigkeit
${t.vmax_kmh != null ? de(t.vmax_kmh) + " km/h" : "liegt nicht vor"}
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}
`; } function quad(t, v) { return `${t}
${v.map((x) => `
${x[0]}
${x[1]}
`).join("")}
`; } /* Der Kachelkopf traegt seit der Designpruefung 2026-08-30 denselben drehenden Pfeil wie die Aufklapp-Zeilen in Fahrten und Tanken. Vorher sah die Statistik aus wie fuenf feste Anzeigen - dass sich dahinter die gesamte Auswertung oeffnet, war an nichts zu erkennen. */ function statTile(id, label, fig, unit, extra, body) { return `
${body}
`; } /* Echte Auswertung aus TRIPS/FILLS statt Beispielwerten. Nur abgeschlossene Fahrten/Tankvorgänge zählen (distance_km bzw. liters gesetzt) - offene/ unvollständige Einträge haben noch keinen belastbaren Wert. Der Verbrauch ist eine Näherung (getankte Menge im Zeitraum ÷ gefahrene km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen l/100km-Wert. */ /* Länge der rollenden Zeiträume in Tagen. Bis zum 02.09.2026 waren die Spannen kalendarisch: Jahr ab 1. Januar, Monat ab dem Ersten, Woche ab Montag, Tag ab Mitternacht. Das ergab regelmäßig einen unmöglichen Vergleich — am 02.09.2026 stand in der realen Instanz "Monat" UNTER "Woche", weil die Woche am Montag, dem 31.08., begann und damit zwei Tage mitzählte, die der Monat nicht kennt. Zwischen Monat und Jahr passiert dasselbe am Jahreswechsel. Rollende Fenster können das nicht: 1 ⊂ 7 ⊂ 30 ⊂ 365 gilt an jedem Tag des Jahres. Der Preis ist, dass "Jahr" nicht mehr das Kalenderjahr meint, sondern die letzten 365 Tage — so entschieden vom Eigentümer am 02.09.2026. Gleichlautend in companion-app/src/daten/statistik.ts (ZEITRAUM_TAGE). */ const ZEITRAUM_TAGE = { jahr: 365, monat: 30, woche: 7, tag: 1 }; function periodStart(art) { // Alles, was je erfasst wurde - kein Zeitraum, sondern die Summe. if (art === "absolut") return new Date(0); return new Date(Date.now() - ZEITRAUM_TAGE[art] * 24 * 60 * 60 * 1000); } const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art)); const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art)); function istNachtZeit(ts) { const z = new Date(ts).toTimeString().slice(0, 5), von = CONFIG.nachtVon, bis = CONFIG.nachtBis; return von > bis ? (z >= von || z < bis) : (z >= von && z < bis); } /* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur zwischen den Orten, nie am Pfeil. */ function strecke(t) { // Der Ortsname kommt mit der Fahrt (start_stadt), aufgeloest im Backend. // // Eine VON HAND eingetragene Anschrift sticht ihn: wer "Zuhause" eintraegt, // will das auch in der Liste lesen und nicht den Ortsnamen dazu. Erkennbar // an edited_fields - genau dafuer fuehrt das Backend die Liste. const handisch = new Set(t.edited_fields || []); const ortsname = (seite) => { const adresse = t[seite + "_address"]; if (adresse && handisch.has(seite + "_address")) return adresse; return t[seite + "_stadt"] || adresse || null; }; const a = ortsname("start"); const b = ortsname("end"); if (!a && !b) return "Fahrt ohne Ortsangabe"; if (!b) return esc(a); if (!a) return esc(b); return `${esc(a)} → ${esc(b)}`; } /* A1.1: Der Verbrauch einer einzelnen Fahrt ist eine Näherung aus der Literstand-Differenz über die Fahrt (verbrauch_aus_literstaenden() im Backend) - kein vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert (Trip-Detail zeigt das schon korrekt als "Näherung aus Tankfüllstand", siehe oben). */ const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null); /* Mittelwert der genäherten Fahrtverbräuche, nach Distanz gewichtet. */ function schnittFahrtVerbrauch(list) { const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0); const km = sum(mit, (t) => t.distance_km); return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–"; } /* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte oder ein Tankvorgang noch unvollständig war. */ function langzeitverbrauch() { const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts)); if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" }; const letzter = tanks[tanks.length - 1]; const bis = new Date(letzter.ts); /* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst - er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */ const mitOdo = tanks.filter((f) => f.odometer_km != null); let km = 0, basis = "erfasste Fahrten", liter; if (mitOdo.length >= 2) { km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km; basis = "Kilometerstand an den Tankvorgängen"; /* Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die Strecke DAVOR - ihn mitzuzählen hat den Verbrauch um einen ganzen Tankinhalt zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %). Korrigiert am 2026-08-31. */ liter = sum(mitOdo.slice(1), (f) => f.liters); } else { km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km); /* Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum letzten Tankstop - dann zählt auch der erste Tankvorgang dazu. */ liter = sum(tanks, (f) => f.liters); } /* Eine negative Strecke ist kein Messwert, sondern ein falscher Kilometerstand an einem Tankvorgang (schon einmal aufgetreten, siehe AGENTS.md Abschnitt AF). Sie darf nicht als Zahl durchgehen. */ if (km < 0) km = 0; const roh = km > 0 ? (liter / km) * 100 : null; /* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen physikalisch unmöglichen Wert als Tatsache anzuzeigen. */ const gueltig = roh != null && roh >= 3 && roh <= 30; /* Auch eine Strecke von null zaehlt als unplausibel, wenn sie aus Kilometerstaenden kommt: dann laufen die Werte an den Tankvorgaengen rueckwaerts. Ohne diesen Zweig staende dort nur "0 km" und "-", ohne jeden Hinweis, woran es liegt. */ const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0; return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: (roh != null && !gueltig) || streckeFehlt }; } function vStat() { const jahr = new Date().getFullYear(); const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag"); const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut"); const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600; // Die grosse Zahl oben in den beiden Kacheln zeigt seit dem 2026-08-31 den // Gesamtwert, nicht mehr den des laufenden Jahres (Ansage des Eigentuemers). // Damit steht "Absolut" dort, wo man es zuerst liest, und die Spalten // darunter bleiben die vier Zeitraeume - fuenf passten auf dem Telefon // nachweislich nicht in eine Zeile. const kmAbs = sum(fAbs, (t) => t.distance_km), hAbs = sum(fAbs, (t) => t.duration_s) / 3600; const lAbs = sum(tAbs, (f) => f.liters), eAbs = sum(tAbs, (f) => f.fuel_total_eur || 0); const tJahr = tankSeit("jahr"); const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0); const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km); const nachtKm = kmJahr - tagKm; const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg"); const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm; /* Die Herleitung des Langzeitwerts - getankte Liter, Bezugsstrecke, letzter Tankstop, der Unplausibel-Hinweis und der Erklärabsatz - ist am 02.09.2026 auf Ansage des Eigentümers aus der ANSICHT genommen worden, nicht aus der Rechnung: langzeitverbrauch() rechnet unverändert weiter und liefert lv.liter/lv.km/lv.basis nach wie vor. In der Kachel bleiben der Wert selbst und die Näherung je Zeitraum. */ const lv = langzeitverbrauch(); return ` ${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h", quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) + quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"]]))} ${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €", quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) + quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))} ${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", "", quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]]))} ${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km", `
Tag · ${de(tagKm)} km Nacht · ${de(nachtKm)} km
Nacht = ${CONFIG.nachtVon} bis ${CONFIG.nachtBis} Uhr`)} ${statTile("s5", "Art der Fahrten", kmJahr > 0 ? de(Math.round((arbeitKm / kmJahr) * 100)) : "–", "% Arbeitsweg", de(arbeitKm) + " km", `
Arbeitsweg · ${de(arbeitKm)} km Privat · ${de(privatKm)} km
Fahrten Arbeitsweg
${de(arbeitFahrten.length)}
Fahrten privat
${de(privatFahrten.length)}
`)}`; } let neuOffen = false; let fahrtOffen = false; let tankBearbeitenOffen = false; let fahrtBearbeitenOffen = false; // Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten } // - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars. let belegEntwurf = null; // Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"): // { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder // eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war. let belegFehler = null; // Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher // Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine // praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null). let belegZielPraefix = null; // Offenes Beleg-Fenster: { tankId, kontext, fehler }. Am Rechner wird der Beleg // hineingezogen, am Telefon aus der Zwischenablage eingefügt (aus der Mail // kopiertes PDF) - der Dateiauswahl-Knopf bleibt als dritter Weg erhalten. let belegPopup = null; /* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events), damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben "art:id"-Schema wie data-go, siehe eintragLoeschen(). */ let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0; let swipeWarGezogen = false; const SWIPE_BREITE = 84; /* Ab diesem Anteil der Zeilenbreite loescht das Loslassen sofort, ohne zweiten Tipp - so macht es iOS, und genau das fehlte: ein voller Wisch blieb wie ein kurzer an der Raste stehen (Wunsch des Eigentuemers, 04.09.2026). 0,55 haelt die Geste absichtsvoll: eine halbe Zeile ist beim seitlichen Scrollen leicht versehentlich erreicht, eine klare Mehrheit nicht. Wortgleich mit WISCH_VOLL_* in bausteine.tsx und FULL_SWIPE_FRACTION in SwipeRow (Design-System). */ const SWIPE_VOLL_ANTEIL = 0.55; const SWIPE_VOLL_MAX = 220; const SWIPE_VOLL_MIN = 120; function swipeVollAb(wrap) { const breite = wrap ? wrap.offsetWidth : 0; return -Math.min(SWIPE_VOLL_MAX, Math.max(SWIPE_VOLL_MIN, breite * SWIPE_VOLL_ANTEIL)); } /* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer Listenzeile UND ganz oben gescrollt). */ let ptrY0 = null, ptrDrag = 0, ptrLaedt = false; async function datenAktualisieren() { try { await HASS.callService(DOMAIN, "jetzt_aktualisieren", {}); const states = await HASS.callWS({ type: "get_states" }); const frisch = states.find((s) => s.entity_id === E.fahrzeugstatus); if (frisch) NACHGELADEN.status = frisch; } catch (e) { // bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt // holt den Stand ohnehin nach } datenLaden(true); } function schliesseAlleSwipes() { ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped")); } function swipeZeile(id, inhaltHtml) { return `
${inhaltHtml}
`; } const LOESCH_TEXT = { trip: "Fahrt löschen?", fill: "Tankvorgang löschen?", sbuch: "Wartungsplan-Eintrag löschen?", bild: "Foto löschen?", batt: "Messwert löschen?", rfarch: "Archivierten Radsatz löschen?", }; const LOESCH_HINWEIS = { trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.", fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.", sbuch: "Der Eintrag wird aus dem Wartungsplan entfernt. Das lässt sich nicht widerrufen.", bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.", batt: "Der Tageseintrag wird aus der Messwertliste entfernt. Das lässt sich nicht widerrufen.", rfarch: "Der archivierte Radsatz wird mit seiner Laufleistung entfernt. Das lässt sich nicht widerrufen.", }; function eintragLoeschen(art, id) { if (art === "trip") { serviceRufen("fahrt_loeschen", { trip_id: id }); TRIPS = TRIPS.filter((t) => t.trip_id !== id); } else if (art === "fill") { serviceRufen("tankvorgang_loeschen", { tank_id: id }); FILLS = FILLS.filter((f) => f.tank_id !== id); } else if (art === "sbuch") { CAR.service.buch.splice(parseInt(id, 10), 1); profilSpeichern(); } else if (art === "batt") { serviceRufen("batterieverlauf_loeschen", { datum: id }); BATTERIEVERLAUF = BATTERIEVERLAUF.filter((e) => e.datum !== id); } else if (art === "rfarch") { // Eigener Dienst statt profilSpeichern - wie schon beim Löschknopf in der // aufgeklappten Zeile (siehe data-reifenarchivloeschen). serviceRufen("reifen_archiv_loeschen", { id }); CAR.reifen.archiv = (CAR.reifen.archiv || []).filter((a) => a.id !== id); } } /* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern und den Verwerfen-Schritt in go(). */ let entwurfIndex = null; const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"]; /* Welche Fassung der Integration gerade installiert ist (Einstellungen -> Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der manifest.json der Integration. Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen" klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/ und www/ von dort über den laufenden Code. Das war nötig, solange die App keine Installationsform hatte, die Home Assistant selbst kennt - jetzt gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */ let APP_VERSION = null; // Ergebnis der letzten "Auf Update prüfen"/"Update installieren" // (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie // geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App // Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht. let INTEGRATION_UPDATE = null; /* Nur "gesetzt/nicht gesetzt" je Dienst - der Token selbst kommt nie ueber die Leitung, siehe zugaenge.py im Backend. */ let ZUGAENGE = []; /* Was am Dongle steht, was noch aussteht - siehe flespi.py im Backend. null, solange kein flespi-Token hinterlegt ist. */ let DONGLE = null; /* Der letzte Datensatz, den das Geraet gemeldet hat - siehe geraet.py im Backend. null, solange weder Meldezeit noch Fahrtsignal zugeordnet sind. */ let LETZTER_DATENSATZ = null; // true, waehrend "Jetzt lesen" laeuft. Wie INTEGRATION_UPDATE_LAEUFT und aus // demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet) // und darf nicht wie ein toter Knopf aussehen. let DONGLE_LAEUFT = false; // Laeuft gerade ein "Fahrt beenden"? Wie DONGLE_LAEUFT: der Knopf soll zeigen, // dass etwas passiert, statt still zu bleiben, bis der Push zurueckkommt. let FAHRTENDE_LAEUFT = false; // "pruefen" | "installieren" | "neustart" | null - während einer dieser drei // Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt // über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen // unbestimmt lange dauern (Netzwerk zu Gitea, Entpacken) und die Oberfläche // das sichtbar machen soll - siehe .lade-spinner's eigener Kommentar dazu. let INTEGRATION_UPDATE_LAEUFT = null; // Nur einmal die "ready"-Verbindung des HA-Websockets belauschen (siehe // deren Verdrahtung in set hass() unten) - HASS wird bei jedem Tick neu // zugewiesen, ohne diese Sperre würde sich sonst pro hass-Push ein weiterer // Listener aufhäufen. let VERBINDUNG_UEBERWACHT = false; /* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel (Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details und die technischen Daten hängen bewusst nicht daran - die bleiben, auch bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen. Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches oder gar kein Badge auf der Übersicht. */ const FAHRZEUG_MODELLE = [ "RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant", "RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7", ]; /* Badge-Zuordnung (www/badges/-positive.svg / -negative.svg, jeweils das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der Präfixe mit einem anderen überschneidet. */ const BADGE_DATEIEN = { "RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7", "S3": "s3", "SQ7": "sq7", }; function badgePraefix() { const titel = CONFIG.fahrzeugtitel || ""; return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null; } /* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen, damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */ const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"]; /* Eigene Berechnung als RÜCKFALL für "Gefahrene Distanz" bei einer NEUEN Erfassung, wenn kein TANK_DISTANZ_SENSOR zugeordnet ist oder dieser gerade keinen gültigen Wert liefert (siehe distanzVorschlag() unten): Differenz zum zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel wie backendseitig in ablage.distanz_seit_letzter_tankung(). Beim Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus f.distance_km (siehe Aufrufer). */ function distanzVorschlagBerechnet(aktuellerKm) { if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return ""; const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a)); if (letzte.odometer_km == null) return ""; const distanz = Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10; // Ein negativer Wert ist nie eine gefahrene Distanz - kann bei einer // fehlerhaft/verzögert erfassten Kilometerstand-Basis des letzten // Tankvorgangs vorkommen (z. B. ein "auto" erkannter Vorgang, dessen // km-Basis über einen späteren, korrigierten/niedrigeren Kilometerstand // hinausragt). Lieber kein Vorschlag als ein irreführender negativer. return distanz < 0 ? "" : distanz; } /* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: bevorzugt den aktuellen Live-Wert von TANK_DISTANZ_SENSOR (vom Fahrzeug selbst berechnet, z. B. FMM003s "calculated_distance_covered_after_the_latest_refueling"), fällt ohne Zuordnung oder bei einem gerade nicht-numerischen Zustand (unavailable/unknown) auf distanzVorschlagBerechnet() zurück - dieselbe "Sensor bevorzugt, eigene Berechnung als Rückfall"-Reihenfolge wie backendseitig in distanz_seit_tankung() (tankerkennung.py), hier nur zur Vorbefüllung im Formular, da der neue Datensatz noch nicht existiert. */ function distanzVorschlag(aktuellerKm) { const id = ENTITAETEN && ENTITAETEN.werte && ENTITAETEN.werte.TANK_DISTANZ_SENSOR; if (id && HASS && HASS.states[id]) { const wert = parseFloat(HASS.states[id].state); if (Number.isFinite(wert)) return wert; } return distanzVorschlagBerechnet(aktuellerKm); } function tankFelder(praefix, f) { // Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf) // überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen // immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2). const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null; const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f; const ts = q && q.ts ? new Date(q.ts) : new Date(); const datum = ts.toISOString().slice(0, 10); const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0"); const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : ""); const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km); const liter = q && q.liters != null ? q.liters : ""; const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : ""; const ersparnis = q && q.discount != null ? q.discount : ""; const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0]; const station = q && q.station_name ? q.station_name : ""; const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0 ? `Preis je Liter: ${de(literpreis(q), 2)} €` : "Preis je Liter: wird berechnet"; return `
km
km
l
${CAR.smartdeal.aktiv ? `
` : ""}
${preisText}`; } function tankFormularWerte(praefix) { const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", ".")); const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", ".")); const kmFeld = $(`#${praefix}Km`).value; const distanzFeld = $(`#${praefix}Distanz`).value; const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null; const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value; return { ts: new Date(`${datum}T${zeit}:00`).toISOString(), liter, kosten, km: kmFeld === "" ? null : parseFloat(kmFeld), distanz: distanzFeld === "" ? null : parseFloat(distanzFeld), ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null, kraftstoff: $(`#${praefix}Kraftstoff`).value, station: $(`#${praefix}Station`).value, }; } function vFuelFormular() { // Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im // Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind. return `${neuOffen ? `
Neuer Tankvorgang ${tankFelder("neuTank", null)} ${belegFehler && belegFehler.context === "neuTank" ? `${esc(belegFehler.meldung)}` : ""}
` : ""} `; } function vFuel() { const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a); /* D5 */ if (!jahre.length) return `
Noch keine Tankvorgänge

Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.

${vFuelFormular()}`; let h = `
`; jahre.forEach((j, ji) => { const fj = neuesteZuerst(FILLS.filter((f) => new Date(f.ts).getFullYear() === j), "ts"); const ersparnisJahr = sum(fj, (f) => f.discount || 0); /* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher konkurrierten drei gleich große rechtsbündige Werte miteinander. */ h += `
`; const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))]; monate.forEach((m, mi) => { const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m); h += `
`; fm.forEach((f) => { const preis = literpreis(f); h += swipeZeile(`fill:${f.tank_id}`, ``); }); h += `
`; }); h += `
`; }); return h + `
${vFuelFormular()}`; } /* Karte des Einzelbelegs - oder die Auskunft, dass es zu dieser Tankstelle keine Position gibt. Welches von beidem, entscheidet BELEG_KARTE: den Merker setzt initMap(), sobald der Nachschlag erfolglos zurueckkommt. Deckungsgleich mit TankstellenKarte.tsx in der App, samt der Unterscheidung zwischen "steht nicht im Beleg" und "findet niemand". */ function belegKarte(f) { const suche = tankstelleSuchtext(f.station_name, f.station_address); const ohne = !suche || (BELEG_KARTE && BELEG_KARTE.id === f.tank_id && BELEG_KARTE.zustand === "keine"); if (!ohne) return mapBoxMitZentrieren(); return `
Keine Position zur Tankstelle

${ suche ? "Zu dieser Anschrift ließ sich kein Kartenpunkt finden. Tragen Sie im Beleg unter „Werte bearbeiten“ eine genauere Anschrift ein, dann erscheint die Tankstelle hier." : "Dieser Beleg nennt weder Tankstelle noch Anschrift. Tragen Sie sie unter „Werte bearbeiten“ nach, dann erscheint die Tankstelle hier." }

`; } /* Tankstelle des Belegs: eigene Zeile ueber die volle Kachelbreite statt der engen Wertespalte, zweizeilig und antippbar. Vorher eine gewoehnliche .row: Name rechts in der Wertespalte (rows-fest, also 120px), die ganze Anschrift als Kleintext darunter - "Hermann Mogler Mineraloelg. GmbH, Heidenheimerstr.6, 89551 Koenigsbronn" stand dort vier- bis fuenfzeilig. Der Tipp fuehrt jetzt in den Kartendienst des Geraets; ohne Name und ohne Anschrift bleibt es eine stumme Zeile, denn ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */ function tankstellenZeile(f) { const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address, f.station_brand); const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen", f.station_brand); const inhalt = `${esc(oben)}${unten ? `${esc(unten)}` : ""}` + (url ? `` : ""); return `
Tankstelle
${ url ? `${inhalt}` : `${inhalt}` }
`; } function vFill(id) { const f = FILLS.find((x) => x.tank_id === id); if (!f) return `
Tankvorgang nicht gefunden
`; const berechneterPreis = literpreis(f); if (tankBearbeitenOffen) { return `
Tankvorgang bearbeiten ${tankFelder("bearbTank", f)} ${belegFehler && belegFehler.context === "bearbTank" ? `${esc(belegFehler.meldung)}` : ""}
`; } return ` ${belegKarte(f)}
Getankt
${f.liters != null ? de(f.liters, 2) : "–"}l
Zeitpunkt
${dedat(new Date(f.ts))}${new Date(f.ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr
Kilometerstand
${f.odometer_km != null ? de(f.odometer_km) + " km" : "unbekannt"}
Gefahrene Distanz
${f.distance_km != null ? de(f.distance_km) + " km" : "unbekannt"}seit der letzten Tankung
Kraftstoff
${esc(f.fuel_type || KRAFTSTOFFSORTEN[0])}
Getankte Liter
${f.liters != null ? de(f.liters, 2) + " l" : "–"}
Kosten
${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}real, nach Rabatt
Preis je Liter
${berechneterPreis != null ? de(berechneterPreis, 2) + " €" : "–"}Kosten ÷ Liter
${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} ${tankstellenZeile(f)}
${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""}
`; } /* ---------------------------------------------------------- Steuerung */ const ICONS = { /* 21 % groesser als das Original, auf die Rastermitte (12,12) gelegt. Live gemessen (getBBox) fuellte das Symbol nur 18,0 x 10,3 von 24 - 43 % der Hoehe, waehrend die vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten) liegen. In einer Reihe liest sich das als "zu klein", und genau so wurde es gemeldet. Ein erster Versuch mit Faktor 1,33 (auf volle Rasterbreite) war falsch: das Symbol reichte danach von x=3,99 bis x=27,9 und wurde rechts abgeschnitten. Beim Nachmessen waren Breite und Hoehe geprueft, aber nicht die Position - bei einer Skalierung immer die RAENDER mitmessen, nicht nur die Groesse. Danach zweimal um 10 % vergroessert (1,1 dann 1,21). Beim zweiten Schritt wird das Symbol zusaetzlich auf die Rastermitte gelegt: seine eigene Mitte liegt bei x=13, nicht 12, und ohne diese Verschiebung stuende es bei 1,21 rechts wieder ueber. Gerechnet inklusive Strichstaerke, die 0,75 ueber die Geometrie hinausragt - Geometrie x 1,11-22,89, mit Strich 0,36-23,64. Mehr geht mit dieser Form nicht: ein Halbkreis ist bauartbedingt halb so hoch wie breit; echte Hoehengleichheit braeuchte einen neu gezeichneten Bogen mit groesserem Winkel. stroke-width gleicht die Skalierung aus (1,5 / 1,21 = 1,240), damit die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */ home: '', /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ audi: '', /* CI-Symbol tour-s statt der frueheren handgezeichneten Route - dieselbe Fuellflaeche-statt-Kontur-Behandlung wie "audi" und "stat" (siehe ICONS_VOLL). */ trips: '', /* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand lassen, ~32-47% Fuellflaeche laut Live-Messung per getBBox()) ist das CI-Symbol randlos gezeichnet (~92% Fuellflaeche) und wirkte dadurch neben "Fahrten"/"Tanken" sichtbar zu gross. Zentrierte Stauchung auf das gemessene Grössenverhältnis (Faktor 0.77, um (12,12) - auf Nutzerwunsch 10% größer als die erste, auf ~45% Fuellflaeche gerechnete Fassung) statt einer eigenen Icon-Groesse: bleibt so bei jeder Breakpoint-Groesse (.tab svg 26px/22px) automatisch im richtigen Verhältnis, ohne zwei Stellen pflegen zu müssen. */ stat: `${CI.pollsS}`, /* Dieselbe Behandlung wie "stat" oben, aus demselben Grund: das CI-Symbol ist randlos gezeichnet und stuende ungeskaliert mit 22 von 24 Einheiten Hoehe als hoechstes Symbol der Leiste da - hoeher als "Fahrten" (21,44) und deutlich hoeher als die gezeichnete Saeule davor (15,50). Faktor 0,84 um die Rastermitte bringt es auf 15,96 x 18,48 und damit auf exakt dieselbe senkrechte Spanne wie "stat" (y 2,76 bis 21,24). Live gemessen per getBBox(), Raender eingeschlossen - nicht geschaetzt. */ fuel: `${CI.fuelStationS}`, }; const TABS = [ { id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" }, { id: "audi", label: "Mein Audi", eyebrow: "", title: "" }, { id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" }, { id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" }, { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, ]; /* Symbole, die als Flaeche gezeichnet sind statt als Kontur */ const ICONS_VOLL = new Set(["audi", "stat", "trips", "fuel"]); let route = { name: "home" }; const ZURUECK = { trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails", vertrag: "vers", steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home", battverlauf: "audi", battliste: "battverlauf", /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren Zurück-Pfeil. */ sicherheit: "home", standort: "home", tuerenklappen: "sicherheit", }; const ICON = { sonne: '', mond: '', }; function themeIcon() { const el = $("#themeicon"); if (el) el.innerHTML = HOST.dataset.theme === "nacht" ? ICON.sonne : ICON.mond; } /* A6: gespeicherte Wahl, sonst Systemeinstellung. */ const THEME_KEY = "audi-dashboard-theme"; function themeGewaehlt() { let gespeichert = null; try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ } if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert; return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht"; } /* Systemumstellung im laufenden Betrieb nachziehen - Gegenstueck zum gleichnamigen Horcher in companion-app/src/theme.ts. themeGewaehlt() liest die Systemeinstellung genau einmal, beim Einhaengen des Elements. Stellte das Geraet danach um - etwa durch die automatische Umschaltung bei Sonnenuntergang -, blieb das Panel auf dem alten Stand stehen, bis jemand neu lud. Das Stylesheet haengt an data-theme, nicht an der Medienabfrage, es blieb also stimmig - aber eben stimmig falsch. Nur solange niemand selbst gewaehlt hat: eine gespeicherte Wahl bleibt. */ function themaSystemNachziehen() { if (!window.matchMedia) return; const dunkel = window.matchMedia("(prefers-color-scheme: dark)"); const nachziehen = () => { let gespeichert = null; try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ } if (gespeichert === "nacht" || gespeichert === "tag") return; if (!HOST) return; const neu = dunkel.matches ? "nacht" : "tag"; if (HOST.dataset.theme === neu) return; HOST.dataset.theme = neu; themeIcon(); tiles(); }; if (dunkel.addEventListener) dunkel.addEventListener("change", nachziehen); else if (dunkel.addListener) dunkel.addListener(nachziehen); } function themeSetzen(modus) { HOST.dataset.theme = modus; try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ } tiles(); } /* ------------------------------------------------------------ Action Sheet B4: ersetzt confirm()/alert(). Ein offenes Sheet ist Zustand wie jeder andere - render() zeichnet es mit, damit es Ansichtswechsel überlebt. */ let sheet = null; const sheetSchliessen = () => { sheet = null; render(); }; function bestaetigen(titel, text, knopf, aktion, destruktiv = true) { sheet = { titel, text, knopf, aktion, destruktiv }; render(); } function hinweis(titel, text) { sheet = { titel, text, knopf: null, aktion: null }; render(); } /* Mehrere Wahlmoeglichkeiten in einem Blatt, plus abgesetztes Abbrechen - Apples Aktionsblatt. Gegenstueck zu in der App (design-system), das dort schon seit dem 31.08.2026 das schwebende Bildmenue abgeloest hat. Warum das Panel nachzieht: das alte .bildmenu war mit position:absolute an die KACHEL geheftet, nicht an den Bildschirm. Bei einer hohen Kachel - den Raedern - lag es dadurch unterhalb des Sichtfelds, und einen Weg hinaus gab es auch nicht: wer das Rad nur versehentlich antippte, fand kein Abbrechen. Beides am 05.09.2026 vom Eigentuemer gemeldet. Das Blatt haengt am Wurzel- Element und ist deshalb immer zu sehen. */ function aktionsblatt(titel, aktionen) { sheet = { titel, text: null, knopf: null, aktion: null, aktionen }; render(); } /* ------------------------------------------------------- Beleg-Upload Ein Weg für alle drei Quellen (Ziehen, Einfügen, Dateiauswahl). Kein serviceRufen() hier: dessen catch loggt nur in die Konsole - siehe den ausführlichen Kommentar an der früheren Aufrufstelle, der Grund gilt unverändert. */ function belegDateiVerarbeiten(datei, tankId, kontext) { if (!datei) return; if (!istPdf(datei)) { belegFehler = { context: kontext, meldung: "Nur PDF-Belege werden unterstützt" }; render(); return; } const leser = new FileReader(); leser.onload = () => { const base64 = leser.result.split(",")[1]; HASS.callService(DOMAIN, "beleg_hochladen", { pdf_base64: base64, dateiname: datei.name || "beleg.pdf", tank_id: tankId } ).catch((err) => { console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err); belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" }; if (!tankId) belegEntwurf = null; render(); }); }; leser.readAsDataURL(datei); } /* Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte oder gezogene Datei kommt je nach Browser und Quelle mit leerem type an. */ function istPdf(datei) { if (!datei) return false; return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || ""); } /* Nimmt das erste PDF aus einer DataTransfer-/Clipboard-Dateiliste. */ function belegAusDateiliste(dateien) { return [...(dateien || [])].find(istPdf) || null; } function belegPopupSchliessenUndLaden(datei) { const ctx = belegPopup; if (!ctx || !datei) return; belegPopup = null; belegDateiVerarbeiten(datei, ctx.tankId, ctx.kontext); render(); } /* Telefon-Weg: das aus der Mail kopierte PDF liegt in der Zwischenablage. navigator.clipboard.read() ist dort der einzige Weg, ohne dass der Nutzer in ein Eingabefeld tippen muss - iOS zeigt dafür seine eigene Einfügen-Abfrage. Der Knopf erscheint deshalb nur auf Tippgeräten, siehe zwischenablageKnopfSinnvoll(). */ async function belegAusZwischenablage() { const ctx = belegPopup; if (!ctx) return; if (!navigator.clipboard || !navigator.clipboard.read) { belegPopup.fehler = "Dieser Browser gibt die Zwischenablage nicht heraus - bitte \"Datei auswählen\"."; render(); return; } try { for (const eintrag of await navigator.clipboard.read()) { const typ = (eintrag.types || []).find((t) => t === "application/pdf"); if (typ) { belegPopupSchliessenUndLaden(new File([await eintrag.getType(typ)], "beleg.pdf", { type: "application/pdf" })); return; } } belegPopup.fehler = "In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder \"Datei auswählen\"."; } catch (err) { belegPopup.fehler = "Kein Zugriff auf die Zwischenablage - bitte \"Datei auswählen\"."; } render(); } /* Der Einfügen-Knopf wird nur auf Tippgeräten angeboten. Am Rechner scheitert er zwangsläufig: clipboard.read() gibt aus Datenschutzgründen nur Text, HTML und Bilder heraus, eine im Explorer kopierte Datei taucht dort nie auf. Und das Tastenkürzel Strg+V hilft dort ebenfalls nicht - ohne Eingabefeld im Fenster hat der Browser kein Ziel für die Einfüge-Geste (vom Nutzer bestätigt). Am Rechner bleiben daher Ziehen und Dateiauswahl. */ function zwischenablageKnopfSinnvoll() { return window.matchMedia && window.matchMedia("(pointer: coarse)").matches; } /* ------------------------------------------------- Historien-Import-Popup Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche Import läuft im Backend (custom_components/audi_dashboard/historienimport.py) und meldet seinen Stand über die Entität sensor.audi_dashboard_import_status: der Dienstaufruf kehrt sofort zurück, weil ein Import über ein Jahr Verlauf Minuten dauern kann. */ let importPopup = null; /* Vorbelegung: die letzten 30 Tage. Wert im Format, das erwartet (Ortszeit, ohne Zeitzone, minutengenau) - genau das, was der Dienst als Ortszeit interpretiert. */ function importZeitraumVorgabe() { const jetzt = new Date(); const vor30 = new Date(jetzt.getTime() - 30 * 86400000); const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000) .toISOString().slice(0, 16); return { von: alsFeld(vor30), bis: alsFeld(jetzt) }; } function importErgebnisText(d) { const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`; const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"]; if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`); if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt")); if (d.fahrten_nicht_gefahren) teile.push(anzahl(d.fahrten_nicht_gefahren, "Zündung ohne gefahrene Strecke", "Zündungen ohne gefahrene Strecke")); if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge")); if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung")); return teile.join(" · "); } function vImportPopup() { if (!importPopup) return ""; const p = importPopup; const fertig = p.ergebnis && !p.laeuft; // Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der // Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG // "Feedback"), statt das Fenster wortlos verschwinden zu lassen. const koerper = fertig ? `
${esc(importErgebnisText(p.ergebnis))}
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten ? ` Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} · ${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr. Weiter zurück bewahrt Home Assistant nichts mehr auf.` : ""} ${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten ? ` Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.` : ""}` : `
${p.fehler ? `${esc(p.fehler)}` : ""} Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein Zeitraum, wird er übersprungen statt doppelt angelegt.`; return `
`; } /* ------------------------------------------------- Datensatz-Popup "Fahrzeugprofil"-Kachel in den Einstellungen: "Datensatz sichern" öffnet vier sofortige, rein clientseitige Exporte (Fahrzeugprofil als JSON, Fahrten/Tankvorgänge/Wartungsplan als CSV - identisch zum bisherigen Verhalten der jetzt zusammengelegten "Fahrzeugprofil"/"Daten ausgeben"-Kacheln). "Datensatz laden" spiegelt dieselben vier Dateien als Datei-Auswahl - Fahrzeugprofil läuft wie zuvor über den bestehenden profil_schreiben-Dienst, die drei CSV-Datensätze über den neuen csv_importieren-Dienst (csv_import.py): eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere am Datensatz bleibt unangetastet - siehe dessen Moduldocstring für die Begründung. */ let datensatzPopup = null; // { modus: "sichern" | "laden" } | null let datensatzLaeuft = null; // welcher Datensatz-Import gerade läuft, oder null let datensatzErgebnis = null; // { art, ... } des zuletzt gelaufenen Imports, oder null let datensatzFehler = null; const DATENSATZ_ARTEN = [ ["fahrzeugprofil", "Fahrzeugprofil"], ["fahrten", "Fahrten"], ["tanken", "Tankvorgänge"], ["service", "Wartungsplan"], ]; function datensatzExportieren(art) { if (art === "fahrzeugprofil") { const a = document.createElement("a"); a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2)); a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove(); } else if (art === "fahrten") { csvDatei("fahrten.csv", ["ID", "Start", "Ende", "km", "Art", "Status"], TRIPS.map((t) => [t.trip_id, t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status])); } else if (art === "tanken") { csvDatei("tankvorgaenge.csv", ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], FILLS.map((f) => [f.tank_id, f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", literpreis(f) != null ? de(literpreis(f), 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""])); } else if (art === "service") { csvDatei("wartungsplan.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"], CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""])); } } function datensatzErgebnisText(e) { if (e.art === "fahrzeugprofil") return "Fahrzeugprofil geladen."; const teile = []; if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`); if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`); if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`); return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden."; } function datensatzImportieren(art, datei) { const leser = new FileReader(); leser.onerror = () => { datensatzLaeuft = null; datensatzFehler = "Die Datei ließ sich nicht lesen."; render(); }; leser.onload = () => { if (art === "fahrzeugprofil") { try { const p = JSON.parse(leser.result); PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) }); bild = 0; datensatzLaeuft = null; datensatzErgebnis = { art }; datensatzFehler = null; } catch (err) { datensatzLaeuft = null; datensatzFehler = "Die Datei ließ sich nicht lesen: " + err.message; } render(); return; } HASS.callService(DOMAIN, "csv_importieren", { art, inhalt: leser.result }) .then(async () => { // importStatusLesen() fragt den Zustand frisch per get_states ab, // statt aus dem lokal zwischengespeicherten HASS.states zu lesen - // dessen state_changed-Update kommt über eine eigene // Websocket-Nachricht rein und muss dem Rückgabewert dieses // Dienstaufrufs nicht unbedingt schon vorausgegangen sein (dieselbe // Begründung wie bei importAusloesen() oben, nur ohne dessen // Warteschleife - der CSV-Import läuft synchron im Backend, bis // hierhin ist der neue Stand schon veröffentlicht). const s = await importStatusLesen(); datensatzLaeuft = null; datensatzErgebnis = { art, ...((s && s.attributes && s.attributes.daten) || {}) }; datensatzFehler = null; render(); datenAktualisieren(); }) .catch((err) => { datensatzLaeuft = null; datensatzFehler = "Der Import ist fehlgeschlagen: " + ((err && err.message) || "unbekannter Fehler"); render(); }); }; datensatzLaeuft = art; datensatzErgebnis = null; datensatzFehler = null; render(); leser.readAsText(datei); } function vDatensatzPopup() { if (!datensatzPopup) return ""; const sichern = datensatzPopup.modus === "sichern"; return `
`; } function vBelegPopup() { if (!belegPopup) return ""; // Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am // Rechner Ziehen bzw. Dateiauswahl. Strg+V wird am Rechner bewusst NICHT // mehr angeboten: ohne Eingabefeld im Fenster hat der Browser kein Ziel für // die Einfüge-Geste, das Tastenkürzel läuft dort also ins Leere. const telefon = zwischenablageKnopfSinnvoll(); return `
`; } function sheetMarkup() { if (!sheet) return ""; const kopf = `
${esc(sheet.titel)}${sheet.text ? `${esc(sheet.text)}` : ""}
`; if (sheet.aktionen) { const knoepfe = sheet.aktionen.map((a, i) => ``).join(""); return `
`; } const aktion = sheet.knopf ? `` : ``; return `
`; } /* ------------------------------------------------------------ Setup-Popup */ const LUPE_SVG = ``; /* Gemeinsames Präfix zweier domain-freier IDs, bis zum letzten gemeinsamen "_" - oder null, wenn sie nicht einmal ein volles Segment teilen. */ /* praefixPaar()/setupAndereIdsOhneDomain()/entitaetIdKurz() sind am 2026-08-31 entfallen: sie kürzten die Kennung des gerade zugeordneten Sensors für den Beispieltext im Setup. Der kommt jetzt fest aus dem Feld-Katalog, damit er sich nicht mehr mit jeder Auswahl ändert - siehe setupFeldzeile(). */ /* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog, einstellungen.py), unabhängig davon, ob und welcher Sensor gerade zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört nirgends mehr hin. binary_sensor liefert immer den Rohzustand "on"/"off" (unabhängig von device_class - die beeinflusst nur, wie ANDERE HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest). Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene Variante bevorzugt, sonst die erste genannte. Ohne passenden Fall (Knopf, GPS-Koordinate) kein Etikett. */ function feldEinheitAnzeige(feld) { if (feld.domains && feld.domains.includes("binary_sensor")) return "on/off"; if (feld.domains && feld.domains.includes("button")) return null; if (feld.units && feld.units.length) { return feld.units.find((u) => u === u.toLowerCase() && u !== u.toUpperCase()) || feld.units[0]; } if (feld.device_classes && (feld.device_classes.includes("date") || feld.device_classes.includes("timestamp"))) { return "Datum"; } return null; } function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) { // Die Position steckt in der Feld-Kennung ("KEY:idx") - sie entscheidet, // welcher Beispielname die Vorschlagsliste anfuehrt. const teile = String(feldId).split(":"); const idx = teile.length === 2 ? Number(teile[1]) : null; const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert, idx); if (!kandidaten.length) return `
Keine passende Entität gefunden
`; return kandidaten.map((e) => { const zustand = HASS.states[e.id] ? HASS.states[e.id].state : null; const unbekannt = zustand === null || zustand === "unavailable" || zustand === "unknown"; const wertAnzeige = unbekannt ? (zustand === null ? "nicht gefunden" : zustand) : `${zustand}${e.unit ? " " + e.unit : ""}`; return ` `; }).join(""); } function entitaetCombo(key, idx) { const feldId = idx == null ? key : `${key}:${idx}`; const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx]; const offen = setupSucheOffen === feldId; const anzeige = offen ? setupSuchtext : entitaetName(wert); // Waehrend gesucht wird nicht gruen faerben - der Text im Feld ist dann // die Sucheingabe und nicht die Zuordnung, die Faerbung wuerde ihm // widersprechen. const treffer = !offen && entitaetPasstZumBeispiel(key, idx, wert); return `
${LUPE_SVG} ${offen ? `
${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}
` : ""}
`; } function setupFeldzeile(feld) { // Einheit und Beispiel gelten beide für die ganze Feld-Rolle und stehen // deshalb auch bei Listenfeldern (vier Positionen wie Tür-/Fenstersensoren). const einheit = feldEinheitAnzeige(feld); /* Das Beispiel steht fest im Feld-Katalog (FELDER in einstellungen.py, Schlüssel "beispiel") und gehört damit zur FELD-ROLLE - genau wie das Einheiten-Etikett daneben. Vorgeschichte in zwei Schritten: Bis 2026-08-28 stand hier ein von Hand ausgedachtes Beispiel. Das war für Reichweite und Sofort-Aktualisierung schlicht falsch, und der Nutzer hat es zu Recht beanstandet. Der Ersatz - die gekürzte Kennung des GERADE ZUGEORDNETEN Sensors - war zwar nie falsch, aber auch kein Beispiel mehr: er änderte sich mit jeder Auswahl mit und sagte einem damit nur zurück, was man eben selbst eingestellt hatte (Nutzerkorrektur 2026-08-31: "Ich möchte, dass xxx permanent bleibt"). Jetzt wieder fest - aber nicht ausgedacht: die Namen sind die der tatsächlich laufenden Integration, abgelesen aus der Zuordnung dieser Installation. Damit ist das Beispiel stabil UND wahr, und beide Beanstandungen sind zugleich erfüllt. Listenfelder bekommen es ebenfalls: dort ist der Name der ersten Position das Muster, nach dem sich die anderen drei richten. */ // Bei Listenfeldern steht das Beispiel NICHT im Kopf, sondern an jeder // Position - siehe unten. Ein einzelner Name im Kopf beschriebe nur die // erste der vier und liesse die anderen drei ohne Erwartungswert dastehen. const erwartet = feld.liste ? "" : feld.beispiel || ""; // Der Klammertext in Label-Größe/-Farbe, der Sensorname darin in derselben // kleinen/grauen Schrift wie die Einheit ("[on/off]"). const hinweis = beispielHinweis(erwartet); const kopf = `
${esc(feld.label)}${einheit ? ` [${esc(einheit)}]` : ""} ${hinweis}
`; if (feld.liste) { return `
${kopf}
${feld.positionen.map((pos, i) => `
${esc(pos)}${beispielHinweis(feldBeispiel(feld, i))} ${entitaetCombo(feld.key, i)} ${entitaetStatusWarnung((setupZuordnung[feld.key] || [])[i])}
`).join("")}
`; } return `
${kopf}${entitaetCombo(feld.key, null)}${entitaetStatusWarnung(setupZuordnung[feld.key])}
`; } const SETUP_GRUPPEN = [ ["fahrterkennung", "Für die Fahrterkennung nötig"], ["uebersicht", "Übersicht & Status"], ["sicherheit", "Sicherheit"], ["standort", "Standort"], ]; function vSetupPopup() { if (!setupOffen) return ""; const katalog = setupKatalog(); return `
`; } let letzteAnsicht = null; let routeStack = []; let navRichtung = null; const istTab = (name) => TABS.some((t) => t.id === name); /* Zu welchem Tab gehoert eine Unterseite? Erst der tatsaechlich gelaufene Weg (routeStack), sonst die ZURUECK-Tabelle. */ function tabVon(name) { if (routeStack.length && istTab(routeStack[0].name)) return routeStack[0].name; let n = name, i = 0; while (n && !istTab(n) && i++ < 8) n = ZURUECK[n]; return istTab(n) ? n : null; } function render() { if (!DATEN_GELADEN) return; // Eine laufende Wischgeste bezieht sich auf Knoten, die render() gleich // durch neue ersetzt - Referenz kappen, bevor sie ins Leere zeigt. swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; const v = $("#view"), b = $("#back"); const merkeScroll = v.scrollTop; const gleicheAnsicht = letzteAnsicht === route.name + "|" + (route.id || ""); let head; if (route.name === "home") { head = ["", "Übersicht"]; v.innerHTML = vHome(); } else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); const t = TRIPS.find((x) => x.trip_id === route.id); } else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); } else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); } else if (route.name === "reifen") { head = ["Fahrzeug", "Räder"]; v.innerHTML = vReifen(); } else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } else if (route.name === "sicherheit") { head = ["Fahrzeug", "Fahrzeugstatus"]; v.innerHTML = vSicherheit(); } else if (route.name === "tuerenklappen") { head = ["Fahrzeug", "Türen und Klappen"]; v.innerHTML = vTuerenKlappen(); } else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); } else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); } else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); } else if (route.name === "vertrag") { head = ["Versicherung", "Vertrag"]; v.innerHTML = vVertragBearbeiten(); } else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); } else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); } else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); } else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); } else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); } else if (route.name === "battliste") { head = ["Batteriespannung", "Messwerte"]; v.innerHTML = vBatterieliste(); } else if (route.name === "sbuch") { head = ["Wartungsplan", "Eintrag"]; v.innerHTML = vSbuch(); } else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); } else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title]; v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); } $("#eyebrow").textContent = head[0]; $("#title").textContent = head[1]; /* B3: Push nach rechts hinein, Pop nach links heraus. */ v.classList.remove("push-rein", "push-raus"); if (navRichtung && !gleicheAnsicht) { void v.offsetWidth; v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein"); } navRichtung = null; // .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays - // die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop // faellt sonst bei jedem render(), z. B. beim Umschalten des Filters, // auf 0 zurueck). const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0; ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup() + vDatensatzPopup(); if (setupOffen) { const koerper = ROOT.querySelector(".setup-koerper"); if (koerper) koerper.scrollTop = setupKoerperScroll || 0; } // Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element // (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst // müsste man nach jedem Tippen erneut anklicken. if (setupOffen && setupSucheOffen) { const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`); if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); } } /* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht dort der Zurück-Pfeil. */ ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none"; const sy = $("#sync"); sy.style.display = route.name === "home" ? "flex" : "none"; sy.querySelector("span").textContent = standAlterText(); b.classList.toggle("on", !istTab(route.name)); v.scrollTop = gleicheAnsicht ? merkeScroll : 0; letzteAnsicht = route.name + "|" + (route.id || ""); /* Die Karten haengen an DOM-Knoten, die render() ueber v.innerHTML gerade ersetzt hat - eine ueberlebende Leaflet-Instanz zeigte danach auf ein abgehaengtes Element und bliebe leer. Sie muessen deshalb bei jedem Durchlauf neu aufgebaut werden; die Haeufigkeit begrenzt stattdessen die Aenderungspruefung in datenLaden(). */ if (MAP) { MAP.remove(); MAP = null; TILE = null; } if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer standortVerfolgungBeenden(); if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } if (route.name === "standort") initStandortMap().catch(() => {}); if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } if (route.name === "home") initVorschauMap().catch(() => {}); /* Menueleiste wie in der myAudi-App: genau ein Eintrag traegt die gefuellte Pille, und zwar auch dann, wenn eine Unterseite offen ist - die gehoert zum Tab, aus dem sie aufgerufen wurde (ZURUECK zeigt darauf). */ const aktiverTab = istTab(route.name) ? route.name : tabVon(route.name); ROOT.querySelectorAll(".tab").forEach((el) => { const an = el.dataset.tab === aktiverTab; el.classList.toggle("on", an); el.setAttribute("aria-current", an ? "page" : "false"); }); } const go = (name, id) => { // Bekanntes Race (siehe Kopfkommentar bei nachladeAnstossen): auf einem // langsam startenden Backend kann die App auf "Lädt …" hängen bleiben, // obwohl die Daten inzwischen längst da wären - der bislang einzige // beobachtete Ausweg war ein Tabwechsel, der zufällig einen frischen // datenLaden()-Durchlauf auslöste. Hier deshalb bei jedem Tab-/Menüklick // aktiv nachschauen, statt nur auf den nächsten hass-Push zu warten - // no-op, falls die Daten längst da sind (datenLaden() rendert dann // ohnehin nichts doppelt Sichtbares). if (!DATEN_GELADEN) datenLaden(false); // Der Merker "zu diesem Beleg gibt es keine Position" gilt nur so lange, wie // man auf ihm steht: nach einer Korrektur der Anschrift soll erneut gesucht // werden, nicht die alte Absage stehen bleiben. BELEG_KARTE = null; // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen // und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren. if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) { CAR.service.buch.splice(entwurfIndex, 1); entwurfIndex = null; } sheet = null; // Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es // geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern. fahrtBearbeitenOffen = false; // Gleiches gilt für "Daten bearbeiten" auf Identität und Technik: wer die // Seite verlässt, soll sie beim nächsten Mal wieder lesend vorfinden. if (name !== "ident") identBearbeitenOffen = false; /* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */ if (istTab(name)) routeStack = []; else if (route.name !== name || route.id !== id) routeStack.push(route); navRichtung = istTab(name) ? null : "rein"; route = { name, id }; render(); }; function zurueck() { sheet = null; fahrtBearbeitenOffen = false; identBearbeitenOffen = false; const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" }; navRichtung = "raus"; route = ziel; render(); } /* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. Seit dem 02.09.2026 folgt die Seite dabei dem Finger (randZeigen): ohne diese Rückmeldung passierte bis zum Loslassen nichts sichtbares, und wer die Geste zu kurz zog, sah nicht, dass er sie überhaupt begonnen hatte. Unter der Auslöseschwelle federt sie zurück - die Geste ist damit widerrufbar, indem man den Finger zurückführt. Gleichlautend in der App (useWischZurueck in Shell.tsx). */ let randX0 = null, randY0 = null; function randZeigen(dx) { const ansicht = $("#view"); if (!ansicht) return; if (dx === null) { ansicht.style.transition = ""; ansicht.style.transform = ""; return; } ansicht.style.transition = "none"; ansicht.style.transform = `translateX(${Math.min(140, dx)}px)`; } function randwischenVerdrahten() { const phone = ROOT.querySelector(".phone"); phone.addEventListener("pointerdown", (e) => { randX0 = null; if (!e.isPrimary || sheet || setupOffen) return; const links = phone.getBoundingClientRect().left; if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; } }); phone.addEventListener("pointermove", (e) => { if (randX0 === null || !e.isPrimary) return; const dx = e.clientX - randX0; // Senkrecht gemeint: die Geste gehört dem Scrollen, nicht uns. if (Math.abs(e.clientY - randY0) > 60) { randX0 = null; randZeigen(null); return; } if (dx > 0) randZeigen(dx); }); phone.addEventListener("pointerup", (e) => { const ausloesen = randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60; randX0 = null; randZeigen(null); if (ausloesen) zurueck(); }); phone.addEventListener("pointercancel", () => { randX0 = null; randZeigen(null); }); } /* -------------------------------------------------------------- Aktionen Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()), Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */ /* Import anstoßen und auf sein Ergebnis warten. Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu schließen. Das Backend meldet seinen Stand über sensor.audi_dashboard_import_status; hier wird dieselbe Entität gepollt, bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des callService-Versprechens, weil der Import-Dienst sofort zurückkehrt, wenn sie im Hintergrund weiterlaufen. */ async function importStatusLesen() { try { const states = await HASS.callWS({ type: "get_states" }); return states.find((x) => x.entity_id === E.importStatus) || null; } catch (e) { return null; } } async function importAusloesen() { if (!importPopup || importPopup.laeuft) return; const von = importPopup.von; const bis = importPopup.bis; if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; } if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; } importPopup.laeuft = true; importPopup.fehler = null; render(); // Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand // ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern. const vorher = await importStatusLesen(); const vorherStand = vorher ? vorher.last_updated : null; try { await HASS.callService(DOMAIN, "historie_importieren", { start: von, ende: bis }); } catch (err) { console.error("audi_dashboard: Import", err); if (importPopup) { importPopup.laeuft = false; importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden."; render(); } return; } // Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine // Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der // Import im Hintergrund sauber zu Ende läuft. for (let versuch = 0; versuch < 120; versuch++) { await new Promise((r) => setTimeout(r, 1000)); if (!importPopup) return; const s = await importStatusLesen(); if (!s || s.last_updated === vorherStand) continue; if (s.state === "fertig") { importPopup.laeuft = false; importPopup.ergebnis = (s.attributes && s.attributes.daten) || {}; render(); // Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat // sie bereits neu veröffentlicht, die App muss sie nur neu lesen. datenAktualisieren(); return; } if (s.state === "fehler") { importPopup.laeuft = false; importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung) || "Der Import ist fehlgeschlagen."; render(); return; } } if (importPopup) { importPopup.laeuft = false; importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter."; render(); } } function serviceRufen(dienst, daten) { // HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole // landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und // die UI optimistisch aktualisiert, sonst würde die Änderung lautlos // verloren gehen (derselbe Fehler, der für den Beleg-Upload und das Setup- // Speichern schon einmal einzeln gefixt wurde - hier zentral für alle // ~20 Aufrufstellen). HASS.callService(DOMAIN, dienst, daten).catch((err) => { console.error("audi_dashboard:", dienst, err); hinweis("Aktion fehlgeschlagen", err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden."); }); } function ereignisseVerdrahten() { /* Tastaturbedienung fuer die beiden Ueberlagerungen. Bisher gab es in der ganzen Datei keinen keydown-Handler: Das Setup-Fenster deklariert role="dialog" aria-modal="true", liess sich aber weder mit Escape schliessen noch verhinderte es, dass der Tabulator in den verdeckten Hintergrund wandert. Beides gehoert zum Vertrag dieser Rolle. Der Handler haengt am Dokument, weil Fokus im Shadow DOM auch ausserhalb von ROOT liegen kann. */ /* Ziehen und Ablegen (Rechner) sowie Einfügen per Tastatur, solange das Beleg-Fenster offen ist. Die Listener hängen am Dokument, weil ein Zug über der Ablagefläche im Shadow DOM sonst schon am Rand verloren geht - und weil ohne preventDefault auf dragover der Browser das PDF einfach selbst öffnet, statt ein drop-Ereignis zu liefern. */ document.addEventListener("dragover", (e) => { if (!belegPopup) return; e.preventDefault(); const zone = ROOT.getElementById("belegDrop"); if (zone) zone.classList.add("darueber"); }); document.addEventListener("dragleave", (e) => { if (!belegPopup || e.relatedTarget) return; const zone = ROOT.getElementById("belegDrop"); if (zone) zone.classList.remove("darueber"); }); document.addEventListener("drop", (e) => { if (!belegPopup) return; e.preventDefault(); const datei = belegAusDateiliste(e.dataTransfer && e.dataTransfer.files); if (datei) { belegPopupSchliessenUndLaden(datei); return; } belegPopup.fehler = "Das war kein PDF."; render(); }); /* Zugabe, kein beworbener Weg: wo der Browser eine Einfüge-Geste ohne Eingabefeld überhaupt zustellt, wird sie mitgenommen. Am Rechner tut Chrome das nicht (deshalb steht Strg+V nirgends mehr als Hinweis), es kostet aber nichts, das Ereignis trotzdem anzunehmen. */ document.addEventListener("paste", (e) => { if (!belegPopup) return; const datei = belegAusDateiliste(e.clipboardData && e.clipboardData.files); if (datei) { e.preventDefault(); belegPopupSchliessenUndLaden(datei); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; } // Während der Import läuft nicht wegklickbar - er läuft im Backend // weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt. if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; } if (sheet) { sheetSchliessen(); e.preventDefault(); return; } if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; } if (setupOffen) { setupSchliessen(); e.preventDefault(); return; } return; } if (e.key !== "Tab" || !setupOffen) return; /* Fokusfalle: innerhalb des Fensters im Kreis wandern statt dahinter. */ const fokussierbar = ROOT.querySelectorAll( '.setup-popup button:not([disabled]), .setup-popup input:not([disabled]), .setup-popup [tabindex]:not([tabindex="-1"])'); if (!fokussierbar.length) return; const erstes = fokussierbar[0]; const letztes = fokussierbar[fokussierbar.length - 1]; const aktiv = ROOT.activeElement; if (e.shiftKey && (aktiv === erstes || !aktiv)) { letztes.focus(); e.preventDefault(); } else if (!e.shiftKey && aktiv === letztes) { erstes.focus(); e.preventDefault(); } }); ROOT.getElementById("tabbar").innerHTML = TABS.map((t) => ``).join(""); ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); /* Auf breiten Bildschirmen steht die Menueleiste als Spalte links; die Marke gehoert dann an deren Kopf statt in die Kopfzeile (Design-Audit). Die Ringe werden aus der Kopfzeile geklont, die Ids im SVG umbenannt, damit der clipPath eindeutig bleibt. Sichtbar nur ueber die Regel in audi-dashboard-ios.css - in Telefonbreite bleibt der Klon verborgen. */ const tabbar = ROOT.getElementById("tabbar"); if (!tabbar.querySelector(".navmarke")) { const marke = document.createElement("div"); marke.className = "navmarke"; marke.setAttribute("aria-hidden", "true"); marke.innerHTML = ROOT.getElementById("marke").innerHTML .replace(/audiRingsClip/g, "audiRingsClipNav"); tabbar.prepend(marke); } /* Der eigene Zuhoerer der Tab-Leiste haengt vor der Sperre des grossen click-Handlers (`if (setupOffen) return`), umging sie deshalb und liess einen Tabwechsel zu, waehrend das Setup-Fenster offen stand - obwohl es aria-modal="true" traegt. Das Fenster schwebte danach ueber einer anderen Seite, mitsamt der nicht gespeicherten Zuordnung. Dieselbe Sperre also auch hier. */ ROOT.getElementById("tabbar").addEventListener("click", (e) => { if (setupOffen || sheet) return; const b = e.target.closest(".tab"); if (b) go(b.dataset.tab); }); ROOT.getElementById("back").addEventListener("click", () => zurueck()); ROOT.getElementById("ringsbtn").addEventListener("click", () => go("einst")); /* In Telefonbreite sind die Ringe oben rechts der Zugang zu den Einstellungen (das Zahnrad ist dort ausgeblendet). */ ROOT.getElementById("marke").addEventListener("click", () => go("einst")); ROOT.getElementById("theme").addEventListener("click", () => { themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht"); themeIcon(); /* Neu zeichnen, damit theme-abhaengige, in JS gesetzte Werte (z. B. die aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */ render(); }); // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe // Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser). // touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ // zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier // preventDefault() die Kontrolle. ROOT.addEventListener("pointerdown", (e) => { // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) - // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist. // Nur Maus/Stift. Der Finger laeuft ueber die Touch-Ereignisse weiter // unten, weil nur dort preventDefault() das native Ueberscrollen wirklich // verhindert - siehe ptrBewegen(). if (e.isPrimary && e.pointerType !== "touch") ptrStarten(e.clientY); const wrap = e.target.closest(".swipe-wrap"); if (!wrap || !e.isPrimary) return; swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0; }); ROOT.addEventListener("pointermove", (e) => { if (e.pointerType !== "touch") ptrBewegen(e.clientY); if (!swipeAktivWrap || !e.isPrimary) return; const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY; if (swipeGeste === null) { if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; swipeGeste = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; if (swipeGeste === "y") { swipeAktivWrap = null; return; } } e.preventDefault(); swipeWarGezogen = true; // Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in // audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch. swipeAktivWrap.classList.add("wischt"); const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; // Nicht mehr bei SWIPE_BREITE abschneiden: ohne den weiteren Weg gibt es // keinen vollen Wisch und keine Rueckmeldung, dass er loescht. swipeDX = Math.min(0, basis + dx); const flaeche = swipeAktivWrap.querySelector(".swipe-delete"); if (flaeche) { // Je weiter gezogen, desto mehr Rot - dieselbe Lesart wie auf iOS. flaeche.style.width = Math.max(SWIPE_BREITE, -swipeDX) + "px"; flaeche.classList.toggle("swipe-delete--scharf", swipeDX <= swipeVollAb(swipeAktivWrap)); } const inhalt = swipeAktivWrap.querySelector(".swipe-content"); inhalt.style.transition = "none"; inhalt.style.transform = `translateX(${swipeDX}px)`; }, { passive: false }); function swipeBeenden() { if (!swipeAktivWrap) return; const wrapJetzt = swipeAktivWrap; const flaecheJetzt = wrapJetzt.querySelector(".swipe-delete"); if (flaecheJetzt) { flaecheJetzt.style.width = ""; flaecheJetzt.classList.remove("swipe-delete--scharf"); } const inhalt = swipeAktivWrap.querySelector(".swipe-content"); inhalt.style.transition = ""; inhalt.style.transform = ""; swipeAktivWrap.classList.remove("wischt"); if (swipeGeste === "x") { if (swipeDX <= swipeVollAb(wrapJetzt)) { // Ganz durchgezogen: jetzt handeln. Die Rueckfrage bleibt - geloescht // werden hier Fahrten, Belege und Messwerte, die nicht wiederkommen. schliesseAlleSwipes(); const knopf = wrapJetzt.querySelector("[data-swipe-loeschen]"); if (knopf) { const [art, id] = knopf.dataset.swipeLoeschen.split(":"); bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); }); } } else if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); wrapJetzt.classList.add("swiped"); } else { wrapJetzt.classList.remove("swiped"); } } swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; // Der nachlaufende Klick derselben Geste feuert synchron noch in diesem // Tick (Maus: immer: Touch: falls vom Browser nicht ohnehin unterdrückt) - // erst danach zurücksetzen, sonst würde ein echter, späterer Tipp (z. B. // auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt. if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0); } function ptrStarten(y) { if (route.name !== "home" || ptrLaedt) return; const ansicht = $("#view"); if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = y; ptrDrag = 0; } } /* Gibt true zurueck, wenn die Geste uns gehoert - der Aufrufer muss dann preventDefault() rufen. Warum das ueber Touch-Ereignisse laufen muss: preventDefault() auf einem Pointer-Ereignis verhindert laut Spezifikation KEIN Scrollen; darueber entscheidet allein touch-action. Auf einem echten Touch-Geraet uebernimmt der Browser die senkrechte Geste deshalb sofort, schickt pointercancel und stellt pointermove ein - die Geste wurde zurueckgesetzt, bevor sie die 56px erreichen konnte. Am Rechner faellt das nie auf, weil eine Maus keine Scroll-Uebernahme kennt; auch synthetische PointerEvents durchlaufen sie nicht. Auf touchmove dagegen wirkt preventDefault() und haelt das native Ueberscrollen an. */ function ptrBewegen(y) { if (ptrY0 === null) return false; const ansicht = $("#view"), dy = y - ptrY0; if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS const ptr = $("#ptr"); if (ptr) { ptr.style.transition = "none"; ptr.style.height = ptrDrag + "px"; ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; } return true; } ptrY0 = null; ptrDrag = 0; const ptr = $("#ptr"); if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } return false; } function ptrBeenden() { if (ptrY0 === null) return; ptrY0 = null; const ptr = $("#ptr"); if (!ptr) { ptrDrag = 0; return; } ptr.style.transition = "height .2s"; if (ptrDrag >= 56) { ptrLaedt = true; ptr.style.height = "56px"; ptr.classList.add("spin"); ptr.querySelector("span").textContent = "Aktualisieren …"; datenAktualisieren().finally(() => { ptrLaedt = false; const ptr2 = $("#ptr"); if (ptr2) { ptr2.style.transition = "height .2s"; ptr2.style.height = "0"; ptr2.classList.remove("spin"); } }); } else { ptr.style.height = "0"; } ptrDrag = 0; } ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); }); ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); }); // Ziehen zum Aktualisieren auf dem Finger: eigener Weg ueber Touch-Ereignisse // (Begruendung in ptrBewegen()). passive:false ist zwingend - ohne das ist // preventDefault() auf touchmove wirkungslos und der Browser scrollt doch. ROOT.addEventListener("touchstart", (e) => { if (e.touches.length === 1) ptrStarten(e.touches[0].clientY); }, { passive: true }); ROOT.addEventListener("touchmove", (e) => { if (e.touches.length !== 1) return; if (ptrBewegen(e.touches[0].clientY)) e.preventDefault(); }, { passive: false }); ROOT.addEventListener("touchend", () => ptrBeenden()); ROOT.addEventListener("touchcancel", () => ptrBeenden()); // Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben // (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt // über die swipe*-Variablen oben, die für horizontales Wischen in Listen // gedacht sind und mit einer zweiten aktiven Berührung nichts anfangen // können. ROOT.addEventListener("pointerdown", (e) => { if (!e.target.closest("#bvChart") || !bvDomain) return; bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (bvZeiger.size === 1) { bvTapKandidat = { x: e.clientX, y: e.clientY }; bvBewegtSeit = false; bvGestStart = { typ: "pan", x: e.clientX, domain: [...bvDomain] }; } else if (bvZeiger.size === 2) { bvTapKandidat = null; const [p1, p2] = [...bvZeiger.values()]; const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); const mitteX = (p1.x + p2.x) / 2; const ankerFrac = Math.max(0, Math.min(1, (mitteX - rahmen.linksPx) / rahmen.breitePx)); bvGestStart = { typ: "pinch", dist: Math.hypot(p1.x - p2.x, p1.y - p2.y), domain: [...bvDomain], ankerFrac, ankerZeit: bvDomain[0] + ankerFrac * (bvDomain[1] - bvDomain[0]), }; } }); ROOT.addEventListener("pointermove", (e) => { if (!bvZeiger.has(e.pointerId) || !bvGestStart) return; bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); e.preventDefault(); if (bvGestStart.typ === "pan" && bvZeiger.size === 1) { const dxPx = e.clientX - bvGestStart.x; if (Math.abs(dxPx) > 4) bvBewegtSeit = true; const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(); const breite = bvGestStart.domain[1] - bvGestStart.domain[0]; const dt = -(dxPx / bvPlotRahmen(rect).breitePx) * breite; bvDomain = bvDomainKlemmen([bvGestStart.domain[0] + dt, bvGestStart.domain[1] + dt]); bvZeichnen(); } else if (bvGestStart.typ === "pinch" && bvZeiger.size === 2) { const [p1, p2] = [...bvZeiger.values()]; const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); const faktor = bvGestStart.dist / Math.max(dist, 1); const breite = (bvGestStart.domain[1] - bvGestStart.domain[0]) * faktor; const start = bvGestStart.ankerZeit - bvGestStart.ankerFrac * breite; bvDomain = bvDomainKlemmen([start, start + breite]); bvZeichnen(); } }, { passive: false }); function bvBeenden(e) { if (!bvZeiger.has(e.pointerId)) return; bvZeiger.delete(e.pointerId); if (bvZeiger.size === 0) { if (bvGestStart && bvGestStart.typ === "pan" && !bvBewegtSeit && bvTapKandidat) { bvAusgewaehlt = bvNaechsterPunkt(bvTapKandidat.x); bvZeichnen(); } bvGestStart = null; bvTapKandidat = null; } else if (bvZeiger.size === 1) { // von Pinch (2 Finger) auf Pan (1 Finger verbleibend) wechseln, statt // die Geste einfach zu beenden - fühlt sich beim Loslassen eines // Fingers sonst wie ein Ruckler an const [rest] = [...bvZeiger.entries()]; bvGestStart = { typ: "pan", x: rest[1].x, domain: [...bvDomain] }; bvBewegtSeit = true; } } ROOT.addEventListener("pointerup", bvBeenden); ROOT.addEventListener("pointercancel", bvBeenden); ROOT.addEventListener("wheel", (e) => { if (!e.target.closest("#bvChart") || !bvDomain) return; e.preventDefault(); const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); const frac = Math.max(0, Math.min(1, (e.clientX - rahmen.linksPx) / rahmen.breitePx)); const ankerZeit = bvDomain[0] + frac * (bvDomain[1] - bvDomain[0]); const faktor = e.deltaY > 0 ? 1.2 : 1 / 1.2; const breite = (bvDomain[1] - bvDomain[0]) * faktor; const start = ankerZeit - frac * breite; bvDomain = bvDomainKlemmen([start, start + breite]); bvZeichnen(); }, { passive: false }); ROOT.addEventListener("click", (e) => { // B4: Das Action Sheet liegt über allem und fängt daher zuerst ab. if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; } const blattAktion = e.target.closest("[data-sheet-aktion]"); if (blattAktion) { const gewaehlt = sheet && sheet.aktionen && sheet.aktionen[Number(blattAktion.dataset.sheetAktion)]; sheet = null; render(); if (gewaehlt && gewaehlt.tun) gewaehlt.tun(); return; } if (e.target.closest("[data-sheet-ok]")) { const aktion = sheet && sheet.aktion; sheet = null; render(); if (aktion) aktion(); return; } if (sheet) return; if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; } if (setupOffen) { if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; } const wahl = e.target.closest("[data-setup-wahl]"); if (wahl) { const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity; const teile = feldId.split(":"); if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId; else setupZuordnung[feldId] = entityId; setupSucheOffen = null; setupSuchtext = ""; render(); return; } if (e.target.closest("[data-setup-speichern]")) { if (setupSpeichertLaeuft) return; const duplikate = setupDuplikate(); if (duplikate.length) { const namen = duplikate.map(entitaetName).join(", "); bestaetigen("Doppelt zugeordnet", `${namen} ${duplikate.length === 1 ? "ist" : "sind"} mehr als einer Rolle zugeordnet. Trotzdem speichern?`, "Trotzdem speichern", setupSpeichernAusfuehren, false); return; } setupSpeichernAusfuehren(); return; } // Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein // Klick, während es schon offen ist (z. B. Cursor neu setzen), soll // keinen render() auslösen - würde die laufende Eingabe unterbrechen. const sucheInput = e.target.closest("[data-setup-suche]"); if (sucheInput) { const feldId = sucheInput.dataset.setupSuche; if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); } return; } // Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon // oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze // Popup zu verlassen. /* Den Filterschalter VOR dem Schliessen der Auswahlliste behandeln. Sonst ersetzt render() unten das Overlay-DOM, bevor das change-Ereignis des Kontrollkaestchens ausgeliefert wird - der Schalter sprang dann beim naechsten Zeichnen einfach zurueck. */ if (e.target.closest("[data-setup-nurpassend]") || e.target.closest(".switch")) { const kasten = ROOT.querySelector("[data-setup-nurpassend]"); if (kasten) { setupNurPassend = !setupNurPassend; render(); return; } } if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; } return; } const bvReset = e.target.closest("[data-bvreset]"); if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; } // Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung // weiterhin) - nicht als Navigation/Aktion werten. if (swipeWarGezogen) { swipeWarGezogen = false; return; } const loeschBtn = e.target.closest("[data-swipe-loeschen]"); const offenerWrap = ROOT.querySelector(".swipe-wrap.swiped"); if (offenerWrap && !loeschBtn) { // Erster Tipp bei offener Wischfläche schließt sie nur - wie bei iOS, // egal ob innerhalb der Zeile (statt zu navigieren) oder außerhalb. schliesseAlleSwipes(); if (offenerWrap.contains(e.target)) return; } if (loeschBtn) { const [art, id] = loeschBtn.dataset.swipeLoeschen.split(":"); bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); }); return; } const tripDel = e.target.closest("[data-tripdel]"); if (tripDel) { const id = tripDel.dataset.tripdel; bestaetigen(LOESCH_TEXT.trip, LOESCH_HINWEIS.trip, "Löschen", () => { eintragLoeschen("trip", id); go("trips"); }); return; } const fillDel = e.target.closest("[data-filldel]"); if (fillDel) { const id = fillDel.dataset.filldel; bestaetigen(LOESCH_TEXT.fill, LOESCH_HINWEIS.fill, "Löschen", () => { eintragLoeschen("fill", id); go("fuel"); }); return; } const sbDel = e.target.closest("[data-sbdel]"); if (sbDel) { const id = sbDel.dataset.sbdel; bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); }); return; } // Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup // mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl // direkt öffnen. const bildKlick = e.target.closest("[data-bildklick]"); if (bildKlick) { const dateiname = bildKlick.dataset.bildklick; const box = bildKlick.querySelector(".bildbox"); const container = bildKlick.closest(".tile"); if (box && box.classList.contains("bild-fehlt")) { const input = container.querySelector(`[data-bildupload="${dateiname}"]`); if (input) input.click(); } else { bildAktionsblatt(dateiname, bildKlick.dataset.bildname || "Foto"); } return; } if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; } if (e.target.closest("[data-sdbestaetigen]")) { const datum = $("#sdPopupBis").value; if (!datum) return; CAR.smartdeal.aktiv = true; CAR.smartdeal.laeuftAb = datum; smartdealPopupOffen = false; profilSpeichern(); render(); return; } if (e.target.closest("[data-bildgalerie]")) { if (galerieGewischt) { galerieGewischt = false; return; } bildWeiter(); return; } if (e.target.closest("[data-sbneu]")) { CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" }); entwurfIndex = 0; go("sbuch", "0"); return; } if (e.target.closest("[data-sbspeichern]")) { entwurfIndex = null; profilSpeichern(); go("service"); return; } if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; } if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; } if (e.target.closest("[data-debug-auf]")) { debugOffen = !debugOffen; render(); return; } if (e.target.closest("[data-nmspeichern]")) { const wert = parseInt($("#nm").value, 10); if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); } nmOffen = false; render(); return; } const kmauf = e.target.closest("[data-kmauf]"); if (kmauf) { kmOffen = kmOffen === kmauf.dataset.kmauf ? null : kmauf.dataset.kmauf; render(); return; } const kmspeichern = e.target.closest("[data-kmspeichern]"); if (kmspeichern) { const satz = kmspeichern.dataset.kmspeichern; const wert = parseFloat($(`#km-${satz}`).value); if (!isNaN(wert) && wert >= 0) { const km = Math.round(wert * 10) / 10; CAR.reifen[satz].km = km; // km wird bewusst nicht ueber profilSpeichern() geschrieben (siehe // Kommentar in configCarZuProfil) - eigener Service, der nur den // Zaehler selbst setzt und referenz_odo_km unangetastet laesst, damit // die naechste automatische Fortschreibung ab diesem neuen Stand // weiterzaehlt statt ihn zu ueberschreiben. serviceRufen("reifen_km_setzen", { satz, km }); } kmOffen = null; render(); return; } if (e.target.closest("[data-neu]")) { neuOffen = !neuOffen; if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null; if (belegFehler && belegFehler.context === "neuTank") belegFehler = null; render(); return; } if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; } if (e.target.closest("[data-fahrtspeichern]")) { serviceRufen("fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt")); fahrtOffen = false; render(); return; } if (e.target.closest("[data-fahrtbearbeiten]")) { fahrtBearbeitenOffen = !fahrtBearbeitenOffen; render(); return; } const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]"); if (fahrtAkt) { serviceRufen("fahrt_aktualisieren", { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") }); fahrtBearbeitenOffen = false; render(); return; } if (e.target.closest("[data-tankspeichern]")) { const w = tankFormularWerte("neuTank"); if (w.liter > 0 && w.kosten > 0) { serviceRufen("tankvorgang_manuell", { ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, }); } neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; } if (e.target.closest("[data-tankspeichernbeleg]")) { const w = tankFormularWerte("neuTank"); const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null; if (w.liter > 0 && w.kosten > 0) { serviceRufen("tankvorgang_manuell", { ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, }); } neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; } if (e.target.closest("[data-tankbearbeiten]")) { tankBearbeitenOffen = !tankBearbeitenOffen; if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null; if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null; render(); return; } const tankAkt = e.target.closest("[data-tankaktualisieren]"); if (tankAkt) { const w = tankFormularWerte("bearbTank"); const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null; serviceRufen("tankvorgang_aktualisieren", { tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, }); // Die Anschrift kann sich gerade geändert haben - dann soll die Karte // erneut suchen statt bei der alten Absage zu bleiben (siehe BELEG_KARTE). BELEG_KARTE = null; tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return; } const belegBtn = e.target.closest("[data-beleg]"); if (belegBtn) { // "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe // belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg- // Ansicht) trägt den Beleg direkt in den bereits gespeicherten // Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen. const ziel = belegBtn.dataset.beleg; const nurParsen = ziel === "neu" || ziel === "bearb"; const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null; const tankId = nurParsen ? undefined : ziel; const kontext = nurParsen ? praefix : ziel; if (belegFehler && belegFehler.context === kontext) belegFehler = null; belegZielPraefix = praefix; // Statt direkt die Dateiauswahl zu öffnen: Fenster mit Ablagefläche // (Rechner) und Einfügen-Knopf (Telefon, PDF aus der Mail kopiert). belegPopup = { tankId, kontext, fehler: null }; render(); return; } if (e.target.closest("[data-belegzu]")) { belegPopup = null; render(); return; } if (e.target.closest("[data-belegeinfuegen]")) { belegAusZwischenablage(); return; } if (e.target.closest("[data-belegdatei]")) { const eingabe = document.createElement("input"); eingabe.type = "file"; eingabe.accept = "application/pdf"; eingabe.onchange = () => belegPopupSchliessenUndLaden(eingabe.files[0]); eingabe.click(); return; } if (e.target.closest("[data-import-oeffnen]")) { const vorgabe = importZeitraumVorgabe(); importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null }; render(); return; } if (e.target.closest("[data-import-zu]")) { if (importPopup && importPopup.laeuft) return; importPopup = null; render(); return; } if (e.target.closest("[data-import-start]")) { importAusloesen(); return; } const dsOeffnen = e.target.closest("[data-datensatz]"); if (dsOeffnen) { datensatzPopup = { modus: dsOeffnen.dataset.datensatz }; datensatzLaeuft = null; datensatzErgebnis = null; datensatzFehler = null; render(); return; } if (e.target.closest("[data-datensatzzu]")) { if (datensatzLaeuft) return; datensatzPopup = null; render(); return; } const dsExport = e.target.closest("[data-datensatzsichern]"); if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; } /* Identität und Technik: Bearbeitungsansicht ein/aus, Positionen der Technik- und Ausstattungsgruppen anlegen/entfernen. */ if (e.target.closest("[data-identbearbeiten]")) { identBearbeitenOffen = !identBearbeitenOffen; render(); return; } const tkdel = e.target.closest("[data-technikdel]"); if (tkdel) { const [gi, i] = tkdel.dataset.technikdel.split("|").map(Number); CAR.technik[gi].posten.splice(i, 1); profilSpeichern(); render(); return; } const tkadd = e.target.closest("[data-technikadd]"); if (tkadd) { const g = CAR.technik[+tkadd.dataset.technikadd]; (g.posten = g.posten || []).push(["Neue Angabe", ""]); profilSpeichern(); render(); return; } const asdel = e.target.closest("[data-ausstattungdel]"); if (asdel) { const [gi, i] = asdel.dataset.ausstattungdel.split("|").map(Number); CAR.ausstattung[gi].sonder.splice(i, 1); profilSpeichern(); render(); return; } const asadd = e.target.closest("[data-ausstattungadd]"); if (asadd) { const g = CAR.ausstattung[+asadd.dataset.ausstattungadd]; (g.sonder = g.sonder || []).push(""); profilSpeichern(); render(); return; } const vseldel = e.target.closest("[data-vselbstdel]"); if (vseldel) { CAR.versicherung.selbst.splice(+vseldel.dataset.vselbstdel, 1); profilSpeichern(); render(); return; } if (e.target.closest("[data-vselbstadd]")) { CAR.versicherung.selbst.push(["", ""]); profilSpeichern(); render(); return; } const oel = e.target.closest("[data-oel]"); if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; } const mode = e.target.closest("[data-mode]"); if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; } const bk = e.target.closest("[data-backup]"); if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; } if (bk && bk.dataset.backup === "export") { // Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in // entitaeten.json und wäre nach einer Wiederherstellung verloren. const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS, entitaeten: (ENTITAETEN && ENTITAETEN.werte) || null }; const a = document.createElement("a"); a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2)); a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); return; } const upd = e.target.closest("[data-update]"); if (upd) { const aktion = upd.dataset.update; // Eigene Promise-Kette statt serviceRufen() (das feuert-und-vergisst) - // diese drei Aktionen brauchen einen sichtbaren Ladezustand, siehe // INTEGRATION_UPDATE_LAEUFT's Kommentar an der Deklaration. if (aktion === "pruefen" || aktion === "installieren") { // Im Großbild-Layout (Seitenleiste statt Tableiste) sprang die Seite // beim Klick sichtbar nach oben, obwohl render() den Scroll eigentlich // wiederherstellt (v.scrollTop = merkeScroll) - die Zustandsänderung // an sensor.audi_dashboard_app_version, die dieser Aufruf auslöst, // verschiebt scrollTop zusätzlich über einen zweiten, äußeren Pfad // (HA reicht jede Zustandsänderung an das eingebettete Panel durch). // Statt die Ursache dort zu jagen: fester Merkwert vor dem Klick, per // rAF nach jedem render() dieses Ablaufs erneut erzwungen - wirkt // gegen render()s eigene Wiederherstellung UND gegen den äußeren Pfad. const merkeScrollUpdate = $("#view").scrollTop; const scrollWiederherstellen = () => requestAnimationFrame(() => { const view = $("#view"); if (view) view.scrollTop = merkeScrollUpdate; }); INTEGRATION_UPDATE_LAEUFT = aktion; render(); scrollWiederherstellen(); HASS.callService(DOMAIN, `update_${aktion}`, {}) .catch((err) => { hinweis( aktion === "pruefen" ? "Prüfung fehlgeschlagen" : "Installation fehlgeschlagen", err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden.", ); }) .finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); scrollWiederherstellen(); }); } else if (aktion === "neustart") { INTEGRATION_UPDATE_LAEUFT = "neustart"; render(); HASS.callService("homeassistant", "restart", {}).catch((err) => { // Bricht die Verbindung normalerweise durch den Neustart selbst ab, // bevor diese Zeile überhaupt liefe - nur ein echter Fehlschlag // (Dienst abgelehnt, o.ä.) kommt hier tatsächlich an. // // "Normalerweise" ist aber keine Garantie: geht der Ruf ueber einen // Vorschaltserver, kommt statt eines Abbruchs eine 502/503/504 // zurueck, und die ist waehrend eines Neustarts kein Fehlschlag, // sondern der erwartete Zustand. Die App hat genau daran gelitten // (siehe Einstellungen.tsx, 2026-09-01); hier dieselbe Einstufung, // damit beide Oberflaechen denselben Fall gleich bewerten. const txt = err && err.message ? String(err.message) : ""; if (/(502|503|504)/.test(txt) || /fetch|network|Failed to fetch/i.test(txt)) return; hinweis("Neustart fehlgeschlagen", txt || "Home Assistant konnte nicht neu gestartet werden."); INTEGRATION_UPDATE_LAEUFT = null; render(); }); } return; } /* Regler "Fahrt beenden": erst hier wird geschrieben. profilSpeichern() stellt ueber denselben Weg auch den Schlaf-Timeout des Dongles - genau deshalb hat der Regler einen eigenen Knopf bekommen. */ if (e.target.closest("[data-pausenzeitspeichern]")) { if (PAUSENZEIT_ENTWURF !== null) CONFIG.pausenzeit = PAUSENZEIT_ENTWURF; PAUSENZEIT_ENTWURF = null; profilSpeichern(); render(); return; } if (e.target.closest("[data-pausenzeitverwerfen]")) { PAUSENZEIT_ENTWURF = null; render(); return; } const satz = e.target.closest("[data-satz]"); if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } const rfArch = e.target.closest("[data-reifenarchivieren]"); if (rfArch) { const k = rfArch.dataset.reifenarchivieren; bestaetigen("Neue Räder anlegen?", "Der aktuelle Stand wird ins Archiv gelegt, der laufende Satz beginnt neu bei 0 km.", "Anlegen", () => { serviceRufen("reifen_archivieren", { satz: k }); }, false); return; } const rfArchDel = e.target.closest("[data-reifenarchivloeschen]"); if (rfArchDel) { const id = rfArchDel.dataset.reifenarchivloeschen; bestaetigen("Archivierten Radsatz löschen?", "Das lässt sich nicht widerrufen.", "Löschen", () => { serviceRufen("reifen_archiv_loeschen", { id }); CAR.reifen.archiv = CAR.reifen.archiv.filter((a) => a.id !== id); render(); }, true); return; } const k = e.target.closest("[data-ics]"); if (k) { if (k.dataset.ics === "service") { const t = gewaehlterTermin(); // Nur exportieren, nichts merken: der Termin lebt im Kalender. ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, $("#svDat").value, CAR.service.autohaus.name, t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse); } else { const datum = $("#rfDat").value; CAR.reifen.wechsel.datum = datum; profilSpeichern(); const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; ics(`${CONFIG.fahrzeugtitel}: Radwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); } return; } const a = e.target.closest("[data-acc]"); if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; } const art = e.target.closest("[data-art]"); if (art) { const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem // naechsten Backend-Push wieder weg - jetzt wandert es mit. serviceRufen("fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art }); render(); return; } // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü- // DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt). if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; } if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = SMAP_STIL === "satellit" ? "strasse" : "satellit"; standortTiles(); return; } if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; } if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; } if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; } if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; } if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; } // Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein // render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren). if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; } // Vor [data-go]: der Knopf sitzt in der Zuletzt-Kachel, deren Zeile sonst // in die Einzelfahrt navigiert. Die Zeile ist waehrend der Fahrt zwar ein // div ohne data-go, aber die Reihenfolge haelt das auch dann, wenn die // Kachel spaeter einmal anders gebaut wird. if (e.target.closest("[data-fahrtbeenden]")) { // Nicht ueber serviceRufen(): das feuert und vergisst. Hier soll der Knopf // zeigen, dass gerade geschlossen wird - dieselbe Mechanik wie beim Dongle. if (FAHRTENDE_LAEUFT) return; FAHRTENDE_LAEUFT = true; render(); HASS.callService(DOMAIN, "fahrt_jetzt_beenden", {}) .catch((f) => console.error("fahrt_jetzt_beenden", f)) .finally(() => { FAHRTENDE_LAEUFT = false; render(); }); return; } if (e.target.closest("[data-dongle]")) { // Nicht ueber serviceRufen(): das feuert und vergisst, hier soll der Knopf // aber zeigen, dass gerade zwei Netze befragt werden - und danach wieder // freigeben. Das Ergebnis (auch ein Fehler) kommt ueber die veroeffentlichte // Entitaet zurueck, siehe flespi.py; deshalb hier nur der Ladezustand. if (DONGLE_LAEUFT) return; DONGLE_LAEUFT = true; render(); HASS.callService(DOMAIN, "dongle_lesen", {}) .catch((f) => console.error("dongle_lesen", f)) .finally(() => { DONGLE_LAEUFT = false; render(); }); return; } const tokSpeichern = e.target.closest("[data-tokenspeichern]"); if (tokSpeichern) { const dienst = tokSpeichern.dataset.tokenspeichern; const feld = ROOT.querySelector(`[data-tokenfeld="${dienst}"]`); const wert = feld ? feld.value.trim() : ""; if (!wert) { hinweis("Kein Token eingegeben", "Bitte erst einen Token eintragen."); return; } // Das Feld sofort leeren: der Wert ist ab hier nicht mehr unser Geschäft, // und ein stehengebliebener Token im DOM wäre genau das, was zugaenge.py // für die Rückgabe ausschließt. if (feld) feld.value = ""; serviceRufen("zugang_setzen", { dienst, token: wert }); return; } const tokLoeschen = e.target.closest("[data-tokenloeschen]"); if (tokLoeschen) { const dienst = tokLoeschen.dataset.tokenloeschen; const titel = (ZUGAENGE.find((z) => z.dienst === dienst) || {}).titel || dienst; bestaetigen( "Token löschen?", `Der hinterlegte Token für ${titel} wird entfernt. Die zugehörige Funktion ist danach abgeschaltet, bis ein neuer eingetragen wird.`, "Löschen", () => serviceRufen("zugang_setzen", { dienst, token: "" }), ); return; } const g = e.target.closest("[data-go]"); if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } }); ROOT.addEventListener("input", (e) => { if (e.target.dataset.pausenzeit !== undefined) { // Beim Ziehen nur die Anzeige und die Fuellung nachziehen, NICHT // render(): ein Re-Render mitten in der Geste erzeugt den Regler neu und // der Daumen springt aus der Hand. Dieselbe Trennung wie beim Suchfeld // direkt darunter. Gespeichert wird ueberhaupt nicht mehr hier, sondern // erst auf Knopfdruck (data-pausenzeitspeichern). const minuten = parseInt(e.target.value, 10); PAUSENZEIT_ENTWURF = minuten; e.target.style.setProperty("--fuell", (minuten / 60) * 100 + "%"); const feld = e.target.parentElement.querySelector("output"); if (feld) feld.textContent = minuten === 0 ? "sofort" : minuten + " Min."; return; } const sucheFeld = e.target.dataset.setupSuche; if (sucheFeld) { // Nur die Kandidatenliste patchen, nicht render() - ein voller // Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen // und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim // bestehenden data-tankpreisfeld-Handler direkt hier drunter). setupSuchtext = e.target.value; const teile = sucheFeld.split(":"); const key = teile[0]; const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key]; const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`); if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert); return; } // Gleicher Grund wie beim tankpreisfeld-Handler darunter: nur die // Anzeige patchen, kein render() - das würde das gerade bearbeitete Feld // neu erzeugen und den Fokus verlieren. const zeitfeldPraefix = e.target.dataset.fahrtzeitfeld; if (zeitfeldPraefix) { const anzeige = ROOT.getElementById(`${zeitfeldPraefix}DauerAnzeige`); if (anzeige) anzeige.textContent = fahrtDauerText(zeitfeldPraefix); return; } const praefix = e.target.dataset.tankpreisfeld; if (!praefix) return; const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", ".")); const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", ".")); const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`); if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 2)} €` : "Preis je Liter: wird berechnet"; }); ROOT.addEventListener("change", (e) => { if (e.target.dataset.datensatzladen) { const datei = e.target.files[0]; if (!datei) return; datensatzImportieren(e.target.dataset.datensatzladen, datei); return; } if (e.target.dataset.backup === "import") { const datei = e.target.files[0]; if (!datei) return; const leser = new FileReader(); leser.onload = () => { try { const paket = JSON.parse(leser.result); if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)"); PROFIL_ROH = paket.profil; CONFIG = profilZuConfig(paket.profil); CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); TRIPS = paket.fahrten || []; FILLS = paket.tankvorgaenge || []; serviceRufen("backup_wiederherstellen", { profil_json: JSON.stringify(paket.profil), fahrten_json: JSON.stringify(TRIPS), tankvorgaenge_json: JSON.stringify(FILLS), // Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die // aktuelle Zuordnung stehen, statt geleert zu werden. ...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}), }); bild = 0; render(); } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } }; leser.readAsText(datei); return; } if (e.target.dataset.vcardImport !== undefined) { const datei = e.target.files[0]; if (!datei) return; const leser = new FileReader(); leser.onload = () => { try { const felder = vcardParsen(leser.result); if (felder.name) CAR.service.autohaus.name = felder.name; if (felder.adresse) CAR.service.autohaus.adresse = felder.adresse; if (felder.tel) CAR.service.autohaus.tel = felder.tel; if (felder.mail) CAR.service.autohaus.mail = felder.mail; profilSpeichern(); render(); } catch (err) { hinweis("vCard konnte nicht gelesen werden", err.message); } }; leser.readAsText(datei); e.target.value = ""; return; } if (e.target.dataset.bildupload !== undefined) { const datei = e.target.files[0]; if (!datei) return; const dateiname = e.target.dataset.bildupload; e.target.value = ""; (async () => { try { const klein = await bildVerkleinern(datei); const daten_base64 = await bildAlsBase64(klein); await HASS.callService(DOMAIN, "bild_hochladen", { dateiname, daten_base64 }); } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } bildVersion = Date.now(); render(); })(); return; } if (e.target.dataset.backupintervall !== undefined) { CONFIG.backupIntervall = e.target.value; profilSpeichern(); return; } const ein = e.target.dataset.einrichten; if (ein) { if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value; else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value; else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value; else CAR[ein] = e.target.value; profilSpeichern(); return; } const ah = e.target.dataset.ah; if (ah) { CAR.service.autohaus[ah] = e.target.value; profilSpeichern(); render(); return; } const sb = e.target.dataset.sb; if (sb) { const [i, feld] = sb.split("|"); const eintrag = CAR.service.buch[+i]; eintrag[feld] = feld === "km" ? (parseInt(e.target.value, 10) || 0) : feld === "kosten" ? (e.target.value === "" ? null : parseFloat(e.target.value)) : e.target.value; // Ein per "Eintrag hinzufügen" angelegter, noch nicht bestätigter Entwurf // wird erst beim Klick auf "Speichern" (data-sbspeichern) persistiert - // sonst würde schon das testweise Ausfüllen einzelner Felder auto-speichern. if (entwurfIndex !== +i) profilSpeichern(); render(); return; } const rf = e.target.dataset.rf; if (rf) { const [k, schluessel] = rf.split("|"); const wert = e.target.value; CAR.reifen[k][schluessel] = wert; profilSpeichern(); render(); return; } const rfa = e.target.dataset.rfa; if (rfa) { const [id, schluessel] = rfa.split("|"); const wert = schluessel === "km" ? parseFloat(e.target.value) || 0 : e.target.value; const eintrag = CAR.reifen.archiv.find((a) => a.id === id); if (eintrag) eintrag[schluessel] = wert; // Eigener Dienst statt profilSpeichern (siehe profilZuCar()) - ändert // nur diesen einen archivierten Satz, statt das ganze Profil (samt // eines womöglich seither fortgeschriebenen km-Stands) zu überschreiben. serviceRufen("reifen_archiv_aktualisieren", { id, [schluessel]: wert }); return; } if (e.target.dataset.schrift !== undefined) { beschriftung = e.target.checked; try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ } ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); return; } if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; } if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; } if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; } if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; } if (e.target.dataset.finAutomatisch !== undefined) { CAR.finAutomatisch = e.target.checked; profilSpeichern(); render(); return; } const vteil = e.target.dataset.vteil; if (vteil) { const [i, feld] = vteil.split("|"); CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value; CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0); profilSpeichern(); render(); return; } // Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem // render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem // Nutzer den gerade offenen Datumswähler weg. if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; } if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; } if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; } const vnotruf = e.target.dataset.vnotruf; if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; } const vvertrag = e.target.dataset.vvertrag; if (vvertrag) { CAR.versicherung[vvertrag] = e.target.value; profilSpeichern(); render(); return; } /* Kein render() beim Tippen: der Cursor säße sonst nach jedem Zeichen wieder am Feldanfang - dieselbe Begründung wie beim Setup-Suchfeld und beim Tankpreisfeld weiter oben. */ const technik = e.target.dataset.technik; if (technik) { const [gi, i] = technik.split("|").map(Number); CAR.technik[gi].posten[i][1] = e.target.value; profilSpeichern(); return; } const ausstattung = e.target.dataset.ausstattung; if (ausstattung) { const [gi, i] = ausstattung.split("|").map(Number); CAR.ausstattung[gi].sonder[i] = e.target.value; profilSpeichern(); return; } const vselbst = e.target.dataset.vselbst; if (vselbst) { const [i, feld] = vselbst.split("|"); CAR.versicherung.selbst[+i][+feld] = e.target.value; profilSpeichern(); render(); return; } if (e.target.dataset.sd !== undefined) { if (e.target.checked) { smartdealPopupOffen = true; render(); } else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); } return; } if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; } if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } // Loslassen: nur den Entwurf festhalten und neu zeichnen, damit der // Speichern-Knopf erscheint. Geschrieben wird erst dort. if (e.target.dataset.pausenzeit !== undefined) { PAUSENZEIT_ENTWURF = parseInt(e.target.value, 10); render(); return; } if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } }); } /* ------------------------------------------------------------ Datenladen */ let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = null, letzterVersionState = null; /* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in frontend_veroeffentlichung.py. */ function belegErgebnisVerarbeiten(daten) { if (!daten) return; if (daten.fehler) { const kontext = daten.tank_id || belegZielPraefix; belegFehler = { context: kontext, meldung: daten.fehler }; if (!daten.tank_id) belegEntwurf = null; /* Der Beleg gehoert schon zu einem Tankvorgang: dorthin springen, statt den Nutzer mit "bereits erfasst" stehen zu lassen. Die Frage, die er in dem Moment hat, ist ja "zu welchem denn?" - siehe belege.py. */ if (daten.vorhandener_tank_id) { belegFehler = { context: daten.vorhandener_tank_id, meldung: daten.fehler }; belegEntwurf = null; go("fill", daten.vorhandener_tank_id); return; } } else if (daten.tank_id) { // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über // tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene // Fehleranzeige für diesen Tankvorgang löschen. if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null; } else if (belegZielPraefix) { belegEntwurf = { praefix: belegZielPraefix, daten }; belegFehler = null; } render(); } /* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch Minuten nach dem Start und nach SPA-interner Navigation weg und zurück beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant, nicht uns. Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger dauern, bis die Integration überhaupt eingerichtet ist und ihre erste Veröffentlichung feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch kein aktives Symbol, weil render() den harten Rücksprung bei !DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half, weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit. Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt wird (disconnectedCallback stoppt den Timer, connectedCallback stößt ihn über datenLaden() wieder an). 2026-08-17: Der Takt darf an keiner Stelle abreißen. Vorher stieg sowohl nachladeAnstossen() (bei fehlendem HASS/callWS) als auch datenLaden() (bei fehlendem HASS) still aus, ohne eine nächste Runde zu planen - danach half nur noch ein zufälliger hass-Push oder ein Klick ins Menü (go() ruft datenLaden() mit auf). Genau das war das gemeldete "Lädt … bis man auf einen Menüpunkt klickt". */ const NACHGELADEN = {}; let nachladeTimer = null; let nachladeVersuche = 0; const NACHLADE_INTERVALL_MS = 2000; const NACHLADE_LANGSAM_MS = 10000; const NACHLADE_SCHNELLE_VERSUCHE = 15; function nachladeAnstossen() { if (nachladeTimer) return; nachladeVersuche++; // Erster Versuch fast sofort statt erst nach vollem Intervall - der // Wettlauf mit dem Start der Integration (siehe Kopfkommentar) ist meist // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer // im schnellen Takt weiterzufragen. const verzoegerung = nachladeVersuche === 1 ? 200 : nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS : NACHLADE_LANGSAM_MS; nachladeTimer = setTimeout(async () => { nachladeTimer = null; // HASS kann hier noch fehlen: panel_custom erzeugt das Element und setzt // hass nicht immer in dieser Reihenfolge. Frueher brach die Schleife in so // einem Fall oben ganz ab (Bedingung !HASS || !HASS.callWS) und wurde nie // wieder angestossen - genau der dauerhaft haengende "Lädt …"-Bildschirm. // Jetzt wird die Runde nur uebersprungen, der Takt laeuft weiter. if (HASS && HASS.callWS) { try { const states = await HASS.callWS({ type: "get_states" }); const finden = (id) => states.find((s) => s.entity_id === id); NACHGELADEN.profil = finden(E.profil) || NACHGELADEN.profil; NACHGELADEN.fahrten = finden(E.fahrten) || NACHGELADEN.fahrten; NACHGELADEN.tank = finden(E.tankvorgaenge) || NACHGELADEN.tank; NACHGELADEN.status = finden(E.fahrzeugstatus) || NACHGELADEN.status; NACHGELADEN.batt = finden(E.batterieverlauf) || NACHGELADEN.batt; // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer, // bis zufaellig ein Push kommt - siehe setupBereit(). NACHGELADEN.entitaeten = finden(E.entitaeten) || NACHGELADEN.entitaeten; } catch (e) { // wird beim naechsten Versuch erneut probiert } } datenLaden(true); // Rueckversicherung: datenLaden() stoesst die naechste Runde selbst an, // aber nur, wenn es nicht vorher aussteigt. Der Takt darf an keiner // Stelle abreissen, solange noch keine Daten da sind. if (!DATEN_GELADEN) nachladeAnstossen(); }, verzoegerung); } async function datenLaden(erzwingen) { if (!HASS) { if (!DATEN_GELADEN) nachladeAnstossen(); return; } const profilState = HASS.states[E.profil] || NACHGELADEN.profil; const fahrtenState = HASS.states[E.fahrten] || NACHGELADEN.fahrten; const tankState = HASS.states[E.tankvorgaenge] || NACHGELADEN.tank; const statusState = HASS.states[E.fahrzeugstatus] || NACHGELADEN.status; const battState = HASS.states[E.batterieverlauf] || NACHGELADEN.batt; const belegState = HASS.states[E.belegErgebnis]; // Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/ // fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern, // nicht Teil des normalen Update-Rhythmus. const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten; const versionState = HASS.states[E.appVersion]; if (versionState && versionState.state) APP_VERSION = versionState.state; if (versionState && versionState.attributes) { INTEGRATION_UPDATE = versionState.attributes.daten ? versionState.attributes.daten.integration_update ?? null : null; ZUGAENGE = versionState.attributes.daten ? versionState.attributes.daten.zugaenge ?? [] : []; DONGLE = versionState.attributes.daten ? versionState.attributes.daten.dongle ?? null : null; LETZTER_DATENSATZ = versionState.attributes.daten ? versionState.attributes.daten.letzter_datensatz ?? null : null; BILD_STAENDE = versionState.attributes.daten ? versionState.attributes.daten.bildstaende ?? null : null; } if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; // sensor.audi_dashboard_app_version änderte sich (z. B. nach "Auf Update // prüfen"/"Update installieren" - siehe dienste.py's update_pruefen()/ // update_installieren()) - ohne diesen Vergleich lief zwar die Zuweisung // von INTEGRATION_UPDATE oben bei jedem Tick mit, aber render() darunter // feuert nur, wenn der geaendert-Block das anstößt. Der prüfte bisher nur // profil/fahrten/tank/status/batt: das Ergebnis der Prüfung/Installation // stand zwar korrekt im Speicher, blieb aber unsichtbar, bis zufällig // eines der anderen fünf sich ändert - sichtbar als "braucht ein manuelles // Neuladen der Seite, um den Neustart-Knopf zu zeigen". const versionGeaendert = versionState !== letzterVersionState; letzterVersionState = versionState; if (belegState && belegState !== letzterBelegState) { letzterBelegState = belegState; belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten); } const geaendert = erzwingen || profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState || tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState; if (geaendert) { letzterProfilState = profilState; letzterFahrtenState = fahrtenState; letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState; const profil = profilState && profilState.attributes ? profilState.attributes.daten : null; if (profil) { const status = (statusState && statusState.attributes && statusState.attributes.daten) || {}; LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated) ? new Date(statusState.last_updated).getTime() : null; PROFIL_ROH = profil; CONFIG = profilZuConfig(profil); CAR = profilZuCar(profil, status); TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || []; FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || []; BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || []; DATEN_GELADEN = true; nachladeVersuche = 0; render(); // Erst nach dem ersten Zeichnen: die Fotos sollen den Aufbau der Seite // nicht ausbremsen, sondern den naechsten Seitenwechsel beschleunigen. bilderVorladen(); return; } } // Nur die Version hat sich geändert (siehe versionGeaendert oben), sonst // nichts - der geaendert-Block darüber ist dafür nicht zuständig (der // bricht bei fehlendem profil sogar früher ab). Ein eigener, schlanker // Aufruf statt profilZuConfig()/profilZuCar() & Co. unnötig neu zu rechnen. if (versionGeaendert && DATEN_GELADEN) { render(); return; } if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart) } /* -------------------------------------------------------------- Element */ class AudiDashboardPanel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); /* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes Modul (dieses hier) mit customElements.define() fertig registriert ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js): _createPanel() kann mehrfach anlaufen, und beim ersten, noch zu frühen document.createElement(tag) ist "audi-dashboard-panel" für den Browser noch ein unbekanntes Element. Die anschließende Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft auf der Instanz an. Sobald customElements.define() greift und der Browser das Element "aufwertet" (führt genau diesen Konstruktor nach), holt die Plattform das NICHT automatisch nach - das gilt nur für über observedAttributes beobachtete HTML-Attribute, nicht für beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass() dauerhaft unerreichbar: _aufbauen() lief zwar (über connectedCallback(), einen echten Lifecycle-Hook), aber datenLaden() nie - sichtbares Symptom war ein dauerhaft hängendes "Lädt …" trotz vorhandener Backend-Daten. */ for (const eigenschaft of ["hass", "narrow", "panel"]) { if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) { const wert = this[eigenschaft]; delete this[eigenschaft]; this[eigenschaft] = wert; } } } set hass(hass) { const erster = !HASS; HASS = hass; if (erster) { this._aufbauen(); } // "ready" feuert bei jedem (Wieder-)Verbindungsaufbau des HA-Websockets, // auch nach einem über den "Jetzt neu starten"-Knopf ausgelösten // HA-Neustart: die Seite verliert dabei die Verbindung, INTEGRATION_UPDATE_ // LAEUFT blieb aber auf "neustart" stehen - dessen einzige Rücksetzung // greift nur im Fehlerfall (siehe deren Klick-Handler), nicht beim // Erfolgsfall, weil dort niemand mehr zusieht, während HA neu startet. // Ohne dies blieb der "Home Assistant startet neu …"-Spinner nach einem // erfolgreichen Neustart stehen, bis der Nutzer die Seite von Hand neu lud. if (!VERBINDUNG_UEBERWACHT && HASS.connection && HASS.connection.addEventListener) { VERBINDUNG_UEBERWACHT = true; HASS.connection.addEventListener("ready", () => { if (INTEGRATION_UPDATE_LAEUFT === "neustart") { INTEGRATION_UPDATE_LAEUFT = null; render(); } }); } datenLaden(false); } get hass() { return HASS; } set narrow(v) { this._narrow = v; } set panel(v) { this._panel = v; } connectedCallback() { HOST = this; if (!this.shadowRoot.querySelector(".phone")) this._aufbauen(); // Absicherung fuer den Fall, dass panel_custom das Element neu verbindet // (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut // auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand // haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt. if (!DATEN_GELADEN) datenLaden(false); } disconnectedCallback() { // Nachlade-Takt anhalten, solange das Panel nicht im Dokument haengt - // connectedCallback() stoesst ihn ueber datenLaden() wieder an. if (nachladeTimer) { clearTimeout(nachladeTimer); nachladeTimer = null; } // Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels // (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem // window-Resize-Horcher und laufenden Kachelabrufen am Leben. if (MAP) { MAP.remove(); MAP = null; TILE = null; } standortVerfolgungBeenden(); if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } } _aufbauen() { // Idempotenz-Sperre: sowohl connectedCallback() als auch set hass() // können _aufbauen() auslösen, je nachdem in welcher Reihenfolge // panel_custom das Element einfügt und mit hass versorgt. Ohne diese // Sperre lief der Aufbau zweimal, verdrahtete jeden Knopf doppelt und // ein Klick auf Tag/Nacht schaltete zweimal um - macht sich gegenseitig // zunichte, sichtbar erst beim tatsächlichen Testen, nicht im Code. if (this.shadowRoot.querySelector(".phone")) return; ROOT = this.shadowRoot; HOST = this; /* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */ HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt(); themaSystemNachziehen(); schriftenLaden(); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = `${STATIK}/audi-dashboard.css?v=${CSS_VERSION}`; ROOT.appendChild(link); /* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab 860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */ const linkIos = document.createElement("link"); linkIos.rel = "stylesheet"; linkIos.href = `${STATIK}/audi-dashboard-ios.css?v=${CSS_VERSION}`; ROOT.appendChild(linkIos); const wrapper = document.createElement("div"); wrapper.innerHTML = `
Übersicht
Ziehen zum Aktualisieren
Lädt …
`; ROOT.appendChild(wrapper); ereignisseVerdrahten(); randwischenVerdrahten(); standortMenuVerdrahten(); galerieWischenVerdrahten(); themeIcon(); setInterval(standAlterTicken, 1000); // Frisch aufgebautes Markup zeigt bis hierhin nur das statische "Lädt …"- // Grundgerüst aus dem Template oben - dasselbe bekannte Rennen wie in // go()'s Kopfkommentar (siehe dort/nachladeAnstossen): panel_custom baut // dieses Element bei einem echten HA-Neustart neu auf (ein neues // customElements-Objekt, nicht dasselbe wiederverwendet), aber die // modul-globalen Daten (CONFIG/CAR/...) und DATEN_GELADEN überleben das // unverändert, weil das JS-Modul selbst nicht neu geladen wird. Ohne // diese Zeile blieb genau dieses frische Grundgerüst für immer stehen, // bis der Nutzer zufällig einen Tab anklickte (go()'s eigener, // unbedingter render()-Aufruf) oder die Seite von Hand neu lud - obwohl // in CONFIG/CAR längst brauchbare (ggf. kurz veraltete, durch den // nächsten Datenabgleich ohnehin gleich aufgefrischte) Daten lagen. if (DATEN_GELADEN) render(); } } if (!customElements.get("audi-dashboard-panel")) { customElements.define("audi-dashboard-panel", AudiDashboardPanel); }