/* ================================================================ 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 pyscript-Zustandsentitäten 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 pyscript.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-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js (siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites Mal abfragen zu muessen. */ const CSS_VERSION = (() => { try { return new URL(import.meta.url).searchParams.get("v") || Date.now(); } catch (e) { return Date.now(); } })(); const $ = (s) => ROOT.querySelector(s); /* ---------------------------------------------------------------- Format */ const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); 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 */ const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0); /* -------------------------------------------------------- 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, version: "1.0", tankvolumen: p.fahrzeug.tankvolumen_liter, nachtVon: p.einstellungen.nacht_von, nachtBis: p.einstellungen.nacht_bis, pauseMin: p.einstellungen.fahrten_pausenzeit_min, markenname: p.einstellungen.kraftstoffanbieter, backupIntervall: p.einstellungen.backup_intervall || "aus", letztesBackup: p.einstellungen.letztes_backup || null, ausfuehrung: p.fahrzeug.ausfuehrung || "", }; } 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, details: p.fahrzeug.details, erstzulassung: p.fahrzeug.erstzulassung, hu: p.fahrzeug.hauptuntersuchung_faellig, 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 || [], 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"), }, 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.hauptuntersuchung_faellig = CAR.hu; p.fahrzeug.fin = CAR.fin; p.fahrzeug.erstzulassung = CAR.erstzulassung; p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung; p.einstellungen.uebersichtsbild = CONFIG.startbild; p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin; p.einstellungen.backup_intervall = CONFIG.backupIntervall; 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; await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); } /* ------------------------------------------------------------- Bilder Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert). Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) - die Flächen behalten dennoch ihre Maße, das Layout springt 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" }, ]; let bild = 0; const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" }; // Alle einzeln hochladbaren Bild-Plätze (§ Fahrzeugbilder-Upload in // Einstellungen) - dieselben festen Dateinamen wie BILDER, plus die separat // geführte Winter-Variante der Seitenansicht. const BILDER_UPLOAD_SLOTS = [...BILDER, { name: "Seitenansicht (Winter)", label: SEITE_WINTER.label, datei: SEITE_WINTER.datei }]; // Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als // Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo() // unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild // oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen. let bildVersion = Date.now(); /* 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 (siehe BILDER_UPLOAD_SLOTS). 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}"` : ""; 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; } 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: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` }; } return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` }; } function bildSrc(i) { return bildInfo(i).src; } function carImg(modus) { if (modus === "fest") { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein"); } const info = bildInfo(bild); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg") + `
${BILDER.map((_, i) => ``).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 ``; } const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]; function szeneKlasse(i) { if (!bodenAn) return ""; if (OHNE_SZENE.includes(BILDER[i].name)) return ""; return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse"; } function bildWeiter() { bild = (bild + 1) % BILDER.length; const img = ROOT.getElementById("carimg"); if (!img) return; const info = bildInfo(bild); img.src = info.src; img.alt = info.label; const box = img.closest("[data-bildbox]"); if (box) { box.classList.remove("bild-fehlt"); // neues Bild kann laden, auch wenn das vorherige fehlte box.querySelector(".platzhalter-label").textContent = info.label; box.querySelector(".platzhalter-datei").textContent = info.datei; } const sz = ROOT.getElementById("szene"); if (sz) sz.className = "szene " + szeneKlasse(bild); [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild)); } /* ---------------------------------------------------- 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 mmjj() sonst "NaN/N" - ein Wert, der wie eine Angabe aussieht und keine ist. */ const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime()); const mmjj = (d) => (gueltigesDatum(d) ? String(d.getMonth() + 1).padStart(2, "0") + "/" + String(d.getFullYear()).slice(2) : "–"); const dedat = (d) => (gueltigesDatum(d) ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) : "–"); function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); } 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(); } function ring(pct) { const r = 64, c = 2 * Math.PI * r, on = (c * pct) / 100; return `
${pct}%
${de(CONFIG.tankvolumen * pct / 100, 1)} Liter
`; } /* ------------------------------------------------------------------ Karte Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ let MAP = null, TILE = null, LEAFLET_LADEND = null; const TILES = { nacht: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", tag: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", }; const ATTR = "© OpenStreetMap · CARTO"; /* 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"; function leafletLaden() { if (window.L) return Promise.resolve(); if (LEAFLET_LADEND) return LEAFLET_LADEND; LEAFLET_LADEND = new Promise((resolve, reject) => { const css1 = document.createElement("link"); css1.rel = "stylesheet"; css1.href = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; document.head.appendChild(css1); const script = document.createElement("script"); script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); return LEAFLET_LADEND; } function mapBox() { 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 fakeTrack(a, b, n = 13) { const pts = []; let seed = Math.round((a[0] + b[1]) * 1000); const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; for (let i = 0; i <= n; i++) { const t = i / n, k = Math.sin(t * Math.PI); pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]); } pts[0] = a; pts[n] = b; return pts; } function tiles() { if (!MAP || !window.L) return; const url = TILES[HOST.dataset.theme]; if (TILE) MAP.removeLayer(TILE); TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); } 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) { const track = fakeTrack([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 }).addTo(MAP); track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); 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 (§7a-Backend-Lücke) } } else if (route.name === "fill") { const f = FILLS.find((x) => x.tank_id === route.id); if (f && f.station_lat != null) { MAP.setView([f.station_lat, f.station_lon], 14); window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "
" + (f.station_address || "")); } 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 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 Audi-CI-Satz (assets/icons/poi-car.svg). Die Spitze sitzt unten, deshalb ankert Leaflet am unteren Rand. */ function fahrzeugMarkerSVG(px = 34) { return ``; } /* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist (siehe fahrterkennung.py), sonst "steht". Ohne echtes Bewegungssignal vom Fahrzeug (siehe AGENTS.md, FMM003/flespi offen) ist das die beste Näherung aus den bereits vorhandenen Fahrtdaten. 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 standortZustand() { const t = TRIPS[0]; if (t && t.status === "offen") return { faehrt: true, seitTs: null, parkplatz: false }; const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null; const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null; return { faehrt: false, seitTs, parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 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 = "Kein Zugriff auf den Standort dieses Geräts"; 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) => { USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "Standortzugriff verweigert" : "Standort nicht verfügbar"; // 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(); } /* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - wie die Kartenkacheln oben (TILES) 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. */ async function standortAdresseAufloesen(lat, lon) { const key = lat.toFixed(3) + "," + lon.toFixed(3); if (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(" "); STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].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; return STANDORT_ADRESSE; } function standortAdresseLaden() { if (!standortBekannt()) return; standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => { const el = $("#standortAdresse"); if (el && adresse) el.textContent = adresse; }); } 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(TILES[HOST.dataset.theme], { 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: [18, 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() { if (!USER_POS) { standortErfassen(); 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(); standortErfassenFallsNoetig(); 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(TILES[HOST.dataset.theme], { 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, 30] }); 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 = { mapLayer: '', carSearch: '', gps: '', poiCar: '', selectAll: '', planRoute: '', upload: '', fuelRange: '', parking: '', }; 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 ? `Fahrzeug fährt` : `${ciSVG(CI.parking)}${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}`}
${ciSVG(CI.fuelRange)} ${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 ""; const t = TRIPS[0], f = FILLS[0]; return `
Zuletzt ${t ? `` : ""} ${f ? `` : ""}
`; } function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; } 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 Ölwechsel-Prognose, eigenständig statt über die allgemeine tagesleistung()/prognose()-Mischrechnung (die für Ölwechsel zu träge ist, weil sie den langfristigen Durchschnitt seit dem allerersten Servicebuch- eintrag mit einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum des letzten tatsächlich durchgeführten Ölwechsels. Logik (wie vorgegeben): km und Tage seit dem letzten Ölwechsel ergeben die aktuelle Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum eingestellten Intervall (Kundeneigene Einstellung, siehe CAR.oel) ergibt das ein Datum - gedeckelt auf spätestens das Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Ölwechsel, falls seither wenig gefahren wurde. */ function oelwechselPrognose() { if (!CAR.odoBekannt) return null; const letzter = letzterEintrag("öl"); if (!letzter) return null; const oel = CAR.oel; const intervallKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); const intervallMonate = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); 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 Ölwechsel bekannt (z. B. // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das // Zeitlimit als einzig belastbare Grenze zeigen. datum = kappDatum; } else { const kmProTagSeitOel = kmSeit / tageSeit; const tageBisZiel = restKm / kmProTagSeitOel; const berechnet = new Date(heute().getTime() + tageBisZiel * dTag); datum = berechnet < kappDatum ? berechnet : kappDatum; } } return { km: zielKm, restKm, datum }; } function terminText(t) { const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null; const restKmAbs = restKm !== null ? Math.abs(restKm) : null; const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum); const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum) : (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch 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 standAlterText() { if (LETZTES_UPDATE_TS == null) return "unbekannt"; const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_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 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 balkenFarbe = pct <= 10 ? "var(--bad)" : pct <= 25 ? "var(--warn)" : "var(--ok)"; const liter = CONFIG.tankvolumen ? Math.round((CONFIG.tankvolumen * pct) / 100) : null; 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. */ return `
${carImg("fest")}
${badge()} ${esc(CONFIG.kennzeichen)}
${CAR.reichweite != null ? de(CAR.reichweite) : "–"}km
${CAR.tankBekannt ? pct + " %" : "–"}
Reichweite${CAR.tankBekannt && liter != null ? ` · noch etwa ${de(liter)} l im Tank` : ""}
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
${(() => { if (!t) return `
Service
kein Eintrag
`; const artLabel = t.art === "Hauptuntersuchung" ? "Haupt-
untersuchung" : esc(t.art); if (t.art === "Ölwechsel") { const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null; const datum = t.kmDatum || t.datum; const teile = []; if (restKm !== null) teile.push(de(restKm / 1000, 1) + " tkm"); if (datum) teile.push(mmjj(datum)); return `
${artLabel}
${teile.join("/ ")}
`; } const teileHU = []; if (t.zuerst === "km") { teileHU.push(de(t.km - CAR.odo) + " km"); if (t.kmDatum) teileHU.push(mmjj(t.kmDatum)); } else { teileHU.push(mmjj(t.datum)); if (t.km !== null && CAR.odoBekannt) teileHU.push(de(Math.abs(t.km - CAR.odo)) + " km"); } return `
${artLabel}
${teileHU.join("/ ")}
`; })()}
${standortKachel()} ${teaser()}`; } function vSicherheit() { const eintraege = CAR.sicherheitscheck; const g = CAR.gesichert; const offen = eintraege.filter((e) => e.ok === false).length; const unbekannt = eintraege.filter((e) => e.ok === null).length; const sammelText = g === null ? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert` : g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`; /* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ return `
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
${esc(sammelText)}
Geprüfte Punkte
${eintraege.length ? eintraege.map((e, i, arr) => `
${esc(e.label)}
${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"}
`).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
${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l${CAR.tankPct} % von ${CONFIG.tankvolumen} 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. */ let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = 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; const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22; const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }); /* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V, 50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - deutlich über dem Ruhespannungs-Maximum. 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.2; 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() { // Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem // Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der // echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver // Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && 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 })) .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; const stufen = bvFarbstufen(); 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 ? `
Ziehen zum Verschieben, mit zwei Fingern oder dem Mausrad zoomen, auf einen Punkt tippen für Datum, Uhrzeit sowie Min-/Max-Spannung ` : ` ${leerText} `} Farbskala Ladezustand (AGM, 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} Näherung auf Basis der täglich niedrigsten gemessenen Spannung (aussagekräftig für die Entladung), keine Kapazitätsmessung - Tageswerte über ${de(AGM_RUHE_MAX_V, 1)} V (Ladevorgang während der Fahrt) fließen nicht ein. Für eine verlässliche Aussage ist ein Lasttest an der Batterie nötig.
`; } function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); } function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (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]; } function bvPlotRahmen(rect) { return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) }; } function bvZeichnen() { const svg = $("#bvSvg"); if (!svg || !bvDomain) return; const sichtbar = bvPunkte.filter((p) => p.t >= bvDomain[0] && p.t <= bvDomain[1]); const basis = sichtbar.length >= 2 ? sichtbar : bvPunkte; let yMin = Math.min(...basis.map((p) => p.v)), yMax = Math.max(...basis.map((p) => p.v)); if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; } const pad = (yMax - yMin) * 0.15; yMin -= pad; yMax += pad; // 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(""); const xLabels = [0, 0.5, 1].map((f) => { const x = BV_ML + f * (BV_W - BV_ML - BV_MR); const t = bvDomain[0] + f * spanne; return `${kurzdat(new Date(t))}`; }).join(""); const stufen = bvFarbstufen(); const kreise = rahmen.map((p) => { const idx = bvPunkte.indexOf(p); const gewaehlt = idx === bvAusgewaehlt; return ``; }).join(""); svg.innerHTML = `${gitter}${kreise}${xLabels}`; 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"; tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`; } 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 `
Anstehende Termine
${termine().map((t, i, arr) => { const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : ""; const iv = intervalle(t); if (!iv) return `
${t.art}
kein Eintrag im Servicebuch
`; const anzeige = iv.hersteller || iv.eigen; const fm = fahrzeugMeldungRoh(t.art); const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; // Forecast-Zeile nur bei Ölwechsel (Kundeneigenes Intervall aus // "Einrichten", eigenständige Reset-nach-Service-Logik, siehe // oelwechselPrognose()) - bei Inspektion und Hauptuntersuchung // bewusst keine Forecast-Zeile mehr. const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; return `
${t.art} ${t.vereinbart ? `
Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""}
${iv.hersteller ? `Herstellervorgabe` : ""} ${fm.km != null || fmDatum ? ` ${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""} ${eigenOel ? `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}` : ""} ` : ` ${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)} `}
`; }).join("")}
Termin vereinbaren ${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : ""); return `
${CAR.service.vereinbart[t.art] ? `` : ""}`; })()}
Autohaus
${esc(sv.autohaus.name)}
${esc(sv.autohaus.adresse)} ${esc(sv.autohaus.tel)} ${sv.autohaus.mail ? `E-Mail schreiben` : ""} In Karten öffnen
Servicebuch ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, ``)).join("") : `
Noch keine Einträge

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

`}
`; } function vIdent() { return `
Fahrzeug
Modell
${esc(CONFIG.fahrzeugtitel)}
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
Fahrgestellnummer
${esc(CAR.fin)}
Motor und Leistung
${esc(CAR.details)}
Erstzulassung
${esc(CAR.erstzulassung)}
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("")} `; } 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; } 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, vereinbart: CAR.service.vereinbart[art] || null }; }; 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; } return [ oelTermin, bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24), bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null), ]; } 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
Kosten fließen in die Jahresübersicht ein
Durchgeführte Arbeiten Beim Verkauf ist diese Liste mehr wert als die Rechnung — sie zeigt, dass gepflegt wurde
${istEntwurf ? `` : ``}`; } function vVers() { const v = CAR.versicherung; return `
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))}
${eur(v.beitrag)}
${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${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.map((g) => `
${esc(g.gruppe)}
${g.posten.map((x, i) => `
${esc(x)}
`).join("")}
`).join("")}`; } 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)} €${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; } })(); let bodenAn = 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(); } function vEinst() { return `
Fahrzeug einrichten Fahrgestellnummer, Kennzeichen, Erstzulassung, Ausführung und Ölwechsel-Intervall — bei der Ersteinrichtung oder wenn sich diese Angaben ändern. ${einrichtenOffen ? `
Ölwechsel-Intervall
${CAR.oel.modus === "hersteller" ? `
Laufleistung
${de(CAR.oel.herstellerKm)} km
Zeit
${CAR.oel.herstellerMonate} Monate
Aus dem Fahrzeug gelesen` : `
`} ` : ""}
Darstellung
Aus zeigt nur die Symbole und gibt etwas Höhe frei
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
Bild der Übersicht
${(() => { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix"); })()}
Fahrzeugbilder Auf ein Bild tippen, um ein eigenes Foto hochzuladen oder zu ersetzen.
${BILDER_UPLOAD_SLOTS.map((s) => `
${bildMitPlatzhalter(`/local/bilder/${s.datei}?v=${bildVersion}`, s.datei, s.label, "carfix mini", null, true)}
${esc(s.name)} ${bildMenuOffen === s.datei ? `
` : ""}
`).join("")}
Kraftstoff-Rabatt
${CAR.smartdeal.aktiv ? `
${dedat(new Date(CAR.smartdeal.laeuftAb))}
` : `Ersparnis und Bildmarke werden ausgeblendet`} ${smartdealPopupOffen ? `
` : ""}
Fahrten zusammenfassen
Minuten
Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, bleibt es eine zusammenhängende Fahrt statt zweier
Fahrzeugprofil Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur diese Datei getauscht — die App selbst bleibt unverändert.
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` : ""}
Daten ausgeben Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen.
Version
Dashboard
${esc(CONFIG.version)}
Fahrzeugdaten
FMM003Zündung · Batterie
Position
FMM003GPS-Tracker
${(() => { const s = UPDATE_STATUS; if (!s || s.stand === "keine_quelle") return ` ${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"} `; if (s.stand === "fehler") return ` ${esc(s.fehler || "Update-Prüfung fehlgeschlagen")} `; if (s.stand === "geprueft" && s.verfuegbar) return ` Update verfügbar — Version ${esc(s.neue_version)} `; return ` Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""} `; })()}
`; } let nmOffen = false; let einrichtenOffen = false; let bildMenuOffen = null; let smartdealPopupOffen = false; /* --------------------------------------------------------- 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 von setupZuordnung beim Öffnen - für den Neustart-Hinweis (welche trigger-gebundenen Felder haben sich geändert) 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) { 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; if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10; return score; } function entitaetKandidaten(feldKey, suchtext, aktuellerWert) { const feld = setupKatalog().find((f) => f.key === feldKey); if (!feld) return []; const text = (suchtext || "").trim().toLowerCase(); const 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 && !feld.units.includes(e.unit)) return false; } if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false; return true; }); liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext)); 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(); } /* Standardwert (aus einstellungen.py, vor jedem Override) für ein Feld bzw. eine Listenposition - Grundlage für den Zurücksetzen-Button. */ function setupStandardwert(key, idx) { const standard = (ENTITAETEN && ENTITAETEN.standardwerte) || {}; const wert = standard[key]; if (idx == null) return wert || ""; return (wert || [])[idx] || ""; } /* 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); } /* Labels der trigger-gebundenen Felder (ZUENDUNG/KM/TANK), deren Wert sich seit dem Öffnen des Popups geändert hat - für den Neustart-Hinweis nach dem Speichern. */ function setupGeaenderteTriggerFelder() { if (!setupZuordnungStart) return []; return setupKatalog() .filter((f) => f.trigger_gebunden && JSON.stringify(setupZuordnung[f.key]) !== JSON.stringify(setupZuordnungStart[f.key])) .map((f) => f.label); } 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("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); } catch (err) { setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return; } const geaendert = setupGeaenderteTriggerFelder(); setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render(); if (geaendert.length) { bestaetigen("Neustart nötig", `Wirkt für ${geaendert.join(", ")} erst nach einem Neustart von Home Assistant.`, "Jetzt neu starten", setupNeustartAusloesen, false); } })(); } function setupNeustartAusloesen() { (async () => { try { await HASS.callService("pyscript", "audi_dashboard_neustart", {}); hinweis("Neustart ausgelöst", "Home Assistant startet neu - die App lädt sich danach automatisch neu."); } catch (err) { hinweis("Neustart fehlgeschlagen", err.message); } })(); } /* 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"; return `
${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")}
${bildMenuOffen === radDatei ? `
` : ""}
${k === "sommer" ? "Sommer" : "Winter"}
${t.km != null ? de(t.km) : "–"}km
${feld(k, "marke", "Hersteller")} ${feld(k, "modell", "Modell")} ${feld(k, "mass", "Größe")} ${feld(k, "dot", "DOT")}
/ bar
${feld(k, "kommentar", "Kommentar")}
`; }; return `
Anzugsmoment der Radschrauben
${de(r.anzugsmoment)}Nm
${nmOffen ? `
Nm
` : ""}
Wechseltermin
${satz("sommer")} ${satz("winter")}`; } 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
` : ""} `; } 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 = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j); 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
`; return ` ${mapBox()}
Distanz
${t.distance_km != null ? de(t.distance_km) : "–"}km
tippen zum Umschalten
Startort
${esc(t.start_address || "unbekannt")}${dedat(new Date(t.ts_start))}
Zielort
${esc(t.end_address || "unbekannt")}
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
${Math.round(t.duration_s / 60)} min
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
Status
${t.status}Quelle: ${t.km_quelle || "–"}
`; } function quad(t, v) { return `${t}
${v.map((x) => `
${x[0]}
${x[1]}
`).join("")}
`; } 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. */ function periodStart(art) { const jetzt = new Date(); if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1); if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1); if (art === "woche") { const d = new Date(jetzt); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d; } const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d; } 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) { const a = t.start_address, b = t.end_address; 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 kommt vom Fahrzeug (OBD) und wird vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */ const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null); /* Mittelwert der gemeldeten 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); const liter = sum(tanks, (f) => f.liters); /* 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"; if (mitOdo.length >= 2) { km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km; basis = "Kilometerstand an den Tankvorgängen"; } else { km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km); } 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; return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig }; } function vStat() { const jahr = new Date().getFullYear(); const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag"); const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600; 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; const lv = langzeitverbrauch(); return ` ${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h", quad("Gefahrene Distanz", [[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", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))} ${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €", quad("Getankte Liter", [[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", lv.bis ? "bis " + dedat(lv.bis) : "–", `
Getankt insgesamt
${de(lv.liter, 1)} l
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""} ${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])} Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)} ${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; // 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; /* 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; /* 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("pyscript", "audi_dashboard_jetzt_aktualisieren", {}); const states = await HASS.callWS({ type: "get_states" }); const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_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: "Servicebuch-Eintrag löschen?", bild: "Foto 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 Servicebuch entfernt. Das lässt sich nicht widerrufen.", bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.", }; function eintragLoeschen(art, id) { if (art === "trip") { serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id }); TRIPS = TRIPS.filter((t) => t.trip_id !== id); } else if (art === "fill") { serviceRufen("audi_dashboard_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(); } } /* 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"]; /* Update-Funktion (Einstellungen -> "Update suchen", siehe updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade- Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt passiv auf den nächsten hass-Push zu warten. */ let UPDATE_STATUS = null; let UPDATE_STATUS_GEHOLT = false; let updatePruefend = false; let updateInstallierend = false; async function updateStatusAbrufen() { try { const states = await HASS.callWS({ type: "get_states" }); const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status"); UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null; } catch (e) { // zeigt dann weiterhin den zuletzt bekannten Stand } } /* 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 6 Avant performance", "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"]; /* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py), hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus f.distance_km (siehe Aufrufer). */ function distanzVorschlag(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 ""; return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10; } 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(q.fuel_total_eur / q.liters, 3)} €` : "Preis je Liter: wird berechnet"; return `
km
${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 = FILLS.filter((f) => new Date(f.ts).getFullYear() === j); 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 = f.liters ? f.fuel_total_eur / f.liters : null; h += swipeZeile(`fill:${f.tank_id}`, ``); }); h += `
`; }); h += `
`; }); return h + `
${vFuelFormular()}`; } function vFill(id) { const f = FILLS.find((x) => x.tank_id === id); if (!f) return `
Tankvorgang nicht gefunden
`; const berechneterPreis = f.liters ? f.fuel_total_eur / f.liters : null; if (tankBearbeitenOffen) { return `
Tankvorgang bearbeiten ${tankFelder("bearbTank", f)} ${belegFehler && belegFehler.context === "bearbTank" ? `${esc(belegFehler.meldung)}` : ""}
`; } return ` ${mapBox()}
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, 3) + " €" : "–"}Kosten ÷ Liter
${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""}
Tankstelle
${esc(f.station_name || "unbekannt")}${esc(f.station_address || "")}
${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""}
`; } /* ---------------------------------------------------------- Steuerung */ const ICONS = { 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: '', trips: '', stat: '', fuel: '', }; 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"]); let route = { name: "home" }; const ZURUECK = { trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails", steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home", battverlauf: "audi", /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren Zurück-Pfeil. */ sicherheit: "home", standort: "home", }; 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"; } 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(); } function sheetMarkup() { if (!sheet) return ""; const kopf = `
${esc(sheet.titel)}${sheet.text ? `${esc(sheet.text)}` : ""}
`; const aktion = sheet.knopf ? `` : ``; return `
`; } /* ------------------------------------------------------------ Setup-Popup */ const LUPE_SVG = ``; function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) { const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert); 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(""); } const RESET_SVG = ``; 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); const standard = setupStandardwert(key, idx); const zeigeReset = wert !== standard; return `
${LUPE_SVG} ${zeigeReset ? `` : ""} ${offen ? `
${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}
` : ""}
`; } function setupFeldzeile(feld) { const kopf = `
${esc(feld.label)} ${esc(feld.hinweis)} ${feld.trigger_gebunden ? `Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.` : ""}
`; if (feld.liste) { return `
${kopf}
${feld.positionen.map((pos, i) => `
${esc(pos)} ${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); } 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", "Reifen"]; 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", "Sicherheit"]; v.innerHTML = vSicherheit(); } 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 === "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 === "sbuch") { head = ["Servicebuch", "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(); 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 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) => { // 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; /* 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; 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. */ let randX0 = null, randY0 = null; 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("pointerup", (e) => { if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck(); randX0 = null; }); phone.addEventListener("pointercancel", () => { randX0 = 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. */ function serviceRufen(dienst, daten) { HASS.callService("pyscript", dienst, daten).catch((err) => console.error("audi_dashboard:", dienst, err)); } 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. */ document.addEventListener("keydown", (e) => { if (e.key === "Escape") { 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); } ROOT.getElementById("tabbar").addEventListener("click", (e) => { 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 auch die Leaflet-Kacheln den Stil wechseln: die Kachel-URL wird beim Aufbau aus TILES[theme] gewaehlt und nicht nachtraeglich aktualisiert. Ohne dies blieb die Karte bis zum naechsten Backend-Update im alten Stil - seit der Dauerkarte auf der Uebersicht deutlich sichtbar. */ 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. if (e.isPrimary && route.name === "home" && !ptrLaedt) { const ansicht = $("#view"); if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; } } 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 (ptrY0 !== null && e.isPrimary) { const ansicht = $("#view"), dy = e.clientY - ptrY0; if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { e.preventDefault(); 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"; } } else { ptrY0 = null; ptrDrag = 0; const ptr = $("#ptr"); if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } } } 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; const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx)); const inhalt = swipeAktivWrap.querySelector(".swipe-content"); inhalt.style.transition = "none"; inhalt.style.transform = `translateX(${swipeDX}px)`; }, { passive: false }); function swipeBeenden() { if (!swipeAktivWrap) return; const inhalt = swipeAktivWrap.querySelector(".swipe-content"); inhalt.style.transition = ""; inhalt.style.transform = ""; if (swipeGeste === "x") { if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); } else { swipeAktivWrap.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 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(); }); // 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; } 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; } const reset = e.target.closest("[data-setup-reset]"); if (reset) { const feldId = reset.dataset.setupReset; const teile = feldId.split(":"); if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = setupStandardwert(teile[0], Number(teile[1])); else setupZuordnung[feldId] = setupStandardwert(feldId, null); 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; } const bildDel = e.target.closest("[data-bildloeschen]"); if (bildDel) { const dateiname = bildDel.dataset.bildloeschen; bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { (async () => { try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); } catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } bildMenuOffen = null; bildVersion = Date.now(); render(); })(); }); 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, .bildslot"); if (box && box.classList.contains("bild-fehlt")) { const input = container.querySelector(`[data-bildupload="${dateiname}"]`); if (input) input.click(); } else { bildMenuOffen = dateiname; render(); } return; } if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; } const bildErsetzen = e.target.closest("[data-bildersetzen]"); if (bildErsetzen) { const container = bildErsetzen.closest(".tile, .bildslot"); const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`); if (input) input.click(); 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.id === "carimg") { 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-nmspeichern]")) { const wert = parseInt($("#nm").value, 10); if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); } nmOffen = false; 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]")) { const datum = $("#neuFahrtDatum").value, start = $("#neuFahrtStart").value, dauer = parseInt($("#neuFahrtDauer").value, 10) || 0; const tsStart = new Date(`${datum}T${start}:00`); const tsEnd = new Date(tsStart.getTime() + dauer * 60000); serviceRufen("audi_dashboard_fahrt_manuell_anlegen", { ts_start: tsStart.toISOString(), ts_end: tsEnd.toISOString(), art: "privat" }); fahrtOffen = false; render(); return; } if (e.target.closest("[data-tankspeichern]")) { const w = tankFormularWerte("neuTank"); if (w.liter > 0 && w.kosten > 0) { serviceRufen("audi_dashboard_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("audi_dashboard_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("audi_dashboard_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, }); 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; const eingabe = document.createElement("input"); eingabe.type = "file"; eingabe.accept = "application/pdf"; eingabe.onchange = () => { const datei = eingabe.files[0]; if (!datei) return; const leser = new FileReader(); leser.onload = () => { const base64 = leser.result.split(",")[1]; serviceRufen("audi_dashboard_beleg_hochladen", { pdf_base64: base64, dateiname: datei.name, tank_id: tankId }); }; leser.readAsDataURL(datei); }; eingabe.click(); return; } const del = e.target.closest("[data-loeschen]"); if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; 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 pr = e.target.closest("[data-profil]"); if (pr && pr.dataset.profil === "export") { 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(); return; } const bk = e.target.closest("[data-backup]"); if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_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; } if (e.target.closest("[data-update-pruefen]")) { if (!updatePruefend) { updatePruefend = true; render(); (async () => { try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); } catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); } await updateStatusAbrufen(); updatePruefend = false; render(); })(); } return; } if (e.target.closest("[data-update-installieren]")) { if (!updateInstallierend) bestaetigen( "Update installieren?", "Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.", "Installieren", () => { updateInstallierend = true; render(); (async () => { try { await HASS.callService("pyscript", "audi_dashboard_update_installieren", {}); await updateStatusAbrufen(); location.reload(); return; } catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); } await updateStatusAbrufen(); updateInstallierend = false; render(); })(); }, false); return; } const cs = e.target.closest("[data-csv]"); if (cs) { const w = cs.dataset.csv; if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"], TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status])); else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 3) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""])); else csvDatei("servicebuch.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) : ""])); return; } const satz = e.target.closest("[data-satz]"); if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } const k = e.target.closest("[data-ics]"); if (k) { if (k.dataset.ics === "service") { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; const datum = $("#svDat").value; CAR.service.vereinbart[t.art] = datum; profilSpeichern(); ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name, t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse); render(); } else { const datum = $("#rfDat").value; CAR.reifen.wechsel.datum = datum; CAR.reifen.wechsel.vereinbart = $("#rfOk").checked; profilSpeichern(); const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel 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"; 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; } const g = e.target.closest("[data-go]"); if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } }); ROOT.addEventListener("input", (e) => { 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; } 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, 3)} €` : "Preis je Liter: wird berechnet"; }); ROOT.addEventListener("change", (e) => { if (e.target.dataset.profil === "import") { const datei = e.target.files[0]; if (!datei) return; const leser = new FileReader(); leser.onload = () => { 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("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) }); bild = 0; render(); } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } }; leser.readAsText(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("audi_dashboard_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.bildupload !== undefined) { const datei = e.target.files[0]; if (!datei) return; const dateiname = e.target.dataset.bildupload; const leser = new FileReader(); leser.onload = async () => { const daten_base64 = String(leser.result).split(",")[1] || ""; try { await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 }); } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } bildMenuOffen = null; bildVersion = Date.now(); render(); }; leser.readAsDataURL(datei); e.target.value = ""; 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 vb = e.target.dataset.vereinbart; if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); 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; } 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; } 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; } 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; } if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); 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.boden !== undefined) { bodenAn = e.target.checked; render(); 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; } if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } }); } /* ------------------------------------------------------------ Datenladen */ function zustandsDaten(entityId) { const st = HASS && HASS.states[entityId]; return st && st.attributes ? st.attributes.daten : undefined; } let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = 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; } 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 pyscript überhaupt geladen ist und sein @time_trigger("startup") 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 hier nichts explizit, aber ein getimeter Aufruf ohne HASS/callWS bricht oben ohnehin ab). */ 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 || !HASS || !HASS.callWS) return; nachladeVersuche++; // Erster Versuch fast sofort statt erst nach vollem Intervall - der // Wettlauf mit pyscripts beim_start() (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; try { const states = await HASS.callWS({ type: "get_states" }); const finden = (id) => states.find((s) => s.entity_id === id); NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil; NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten; NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank; NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status; NACHGELADEN.batt = finden("pyscript.audi_dashboard_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("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten; } catch (e) { // wird beim naechsten Versuch erneut probiert } datenLaden(true); }, verzoegerung); } async function datenLaden(erzwingen) { if (!HASS) return; const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil; const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten; const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank; const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; // 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["pyscript.audi_dashboard_entitaeten"] || NACHGELADEN.entitaeten; if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; 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; if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); } 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(); } 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() { // 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; } 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(); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = `/local/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 = `/local/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(); themeIcon(); setInterval(standAlterTicken, 1000); } } if (!customElements.get("audi-dashboard-panel")) { customElements.define("audi-dashboard-panel", AudiDashboardPanel); }