/* ================================================================
   Audi-Dashboard — panel_custom Custom Element
   ================================================================
   Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp
   aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant.

   Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert
   (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde
   nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den
   Entitäten der Integration geladen statt fest im Code zu stehen) und die
   DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).

   Bekannte Lücken in diesem Stand:
   - Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
     vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
     gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
   - Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur,
     wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat.
*/

/* ---------------------------------------------------------------------
   Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch
   unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`)
   weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel-
   Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine
   Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein
   <body> existierte.
   --------------------------------------------------------------------- */
let HASS = null;
let ROOT = null;   // this.shadowRoot der Panel-Instanz
let HOST = null;   // die Panel-Instanz selbst (für :host([data-theme]))

let CONFIG = null;
let CAR = null;
let TRIPS = [];
let FILLS = [];
let BATTERIEVERLAUF = [];
let ENTITAETEN = null; // {katalog, werte} aus sensor.audi_dashboard_entitaeten (Setup-Menü)
let DATEN_GELADEN = false;
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
// null, solange keiner bekannt ist (siehe standAlterText()).
let LETZTES_UPDATE_TS = null;

/* Cache-Brecher für audi-dashboard.css und audi-dashboard-ios.css: die
   Stylesheets werden nicht als Modul geladen, sondern als <link> in den
   Shadow-Root gehängt - sie brauchen deshalb ihre eigene Versions-Query.
   Ohne sie bliebe ein Browser, der sie einmal geladen hat, nach einer
   CSS-Änderung auf dem alten Stand hängen, obwohl der Code selbst korrekt
   neu geladen wird.

   Die Zahl steht schon in der eigenen Modul-URL (?v=…, von
   audi-dashboard-panel.js durchgereicht, ursprünglich von der Integration
   beim Anmelden des Panels gesetzt) - eine eigene Abfrage braucht es
   nicht. */
const CSS_VERSION = (() => {
  try {
    return new URL(import.meta.url).searchParams.get("v") || Date.now();
  } catch (e) {
    return Date.now();
  }
})();

/* Basis-URL der ausgelieferten Dateien der Integration (CSS, Badges,
   Markenlogo). Aus der eigenen Modul-URL abgeleitet statt fest verdrahtet:
   damit stimmt sie auch dann, wenn sich der statische Pfad in const.py
   einmal ändert, und es gibt keine zweite Stelle, die mitgepflegt werden
   müsste.

   NICHT hierunter fallen die Fahrzeugfotos: die lädt der Nutzer selbst hoch
   und sie liegen weiterhin unter /local/bilder/ (siehe bilder.py) - im
   Integrationsordner würde sie das nächste Update mitlöschen. */
const STATIK = new URL(".", import.meta.url).href.replace(/\/$/, "");

/* ------------------------------------------------- Namen des Backends
   Die eine Stelle, an der die Entity-IDs und Dienstnamen stehen. Sie sind
   ein Vertrag mit custom_components/audi_dashboard/const.py; die
   Companion-App führt dieselbe Tabelle in src/api/types.ts.

   Bis 2026-08-23 hießen sie pyscript.audi_dashboard_* bzw.
   pyscript.audi_dashboard_<dienst>: der Präfix gehörte pyscript, das die
   Entitäten damals bereitstellte. Seit dem Umbau zur eigenen Integration
   gehören sie ihr auch dem Namen nach. */
const DOMAIN = "audi_dashboard";
const E = {
  profil: "sensor.audi_dashboard_profil",
  fahrten: "sensor.audi_dashboard_fahrten",
  tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge",
  fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus",
  batterieverlauf: "sensor.audi_dashboard_batterieverlauf",
  entitaeten: "sensor.audi_dashboard_entitaeten",
  belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis",
  importStatus: "sensor.audi_dashboard_import_status",
  appVersion: "sensor.audi_dashboard_app_version",
};

const $ = (s) => ROOT.querySelector(s);

/* ---------------------------------------------------------------- Format */
const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d });
const eur = (n) => de(n, 2);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 || [],
    zuendung: status.zuendung ?? null,
    standortLat: status.standort_lat,
    standortLon: status.standort_lon,
    standortGenauigkeitM: status.standort_genauigkeit_m,
    standortZeit: status.standort_zeit,
    oelwechselFaelligTs: status.oelwechsel_faellig_ts,
    oelwechselFaelligKm: status.oelwechsel_faellig_km,
    inspektionFaelligTs: status.inspektion_faellig_ts,
    inspektionFaelligKm: status.inspektion_faellig_km,
    kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert
    // "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag
    // selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne
    // dass der Nutzer manuell eingreifen muss.
    smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab ||
      p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab },
    oel: {
      modus: p.einstellungen.oelwechsel_intervall.modus,
      km: p.einstellungen.oelwechsel_intervall.km,
      monate: p.einstellungen.oelwechsel_intervall.monate,
      herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km,
      herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate,
    },
    versicherung: p.versicherung,
    steuer: { ...p.steuer, faellig: p.steuer.faellig || "" },
    reifen: {
      aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer",
      anzugsmoment: p.reifen.anzugsmoment_nm,
      wechsel: p.reifen.wechsel,
      sommer: reifenSatz("sommer"),
      winter: reifenSatz("winter"),
    },
    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;
  // HIG "Feedback": profilSpeichern() ist der Speicherpfad fuer praktisch
  // jedes editierbare Feld im Panel (~25 Aufrufstellen) und wurde bisher an
  // keiner davon awaited oder abgefangen - ein Fehlschlag war eine unhandled
  // promise rejection ohne jede Rueckmeldung. Zentral hier gefangen, aus
  // demselben Grund wie serviceRufen() weiter unten.
  try {
    await HASS.callService(DOMAIN, "profil_schreiben", { profil_json: JSON.stringify(neuesProfil) });
  } catch (err) {
    console.error("audi_dashboard: profilSpeichern", err);
    hinweis("Speichern fehlgeschlagen", err && err.message ? err.message : "Die Änderung konnte nicht gespeichert werden.");
  }
}

/* ------------------------------------------------------------- Bilder
   Feste Dateinamen laut §7a. Diese Fotos lädt der Nutzer selbst hoch; sie
   liegen deshalb unter /local/bilder/ (also in /config/www/bilder/) und
   nicht bei den ausgelieferten Dateien der Integration - dort würde sie das
   nächste Update mitlöschen (siehe bilder.py).

   Solange ein Foto fehlt, behalten die Flächen ihre Maße und zeigen einen
   Platzhalter, das Layout springt beim Nachliefern also nicht. */
const BILDER = [
  { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" },
  { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" },
  { name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" },
  { name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" },
  { name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" },
  { name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" },
  { name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" },
];
let bild = 0;
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
// 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
   (Bild der Übersicht: Ansicht wählen, dann auf das Bild tippen). Die Maße
   bleiben über die CSS-Klassen erhalten, das Layout springt beim Nachliefern
   eines Fotos nicht. */
function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) {
  const idTeil = imgId ? ` id="${imgId}"` : "";
  return `<div class="bildbox ${klasse}" data-bildbox title="${esc(label)}">
    <img class="${klasse}"${idTeil} src="${src}" alt="${esc(label)}" data-bildlabel="${esc(label)}" data-bilddatei="${esc(datei)}"
         onerror="this.closest('[data-bildbox]').classList.add('bild-fehlt')">
    <div class="platzhalter ${klasse}">
      <svg viewBox="0 0 24 24" class="platzhalter-icon" fill="currentColor"><path fill-rule="evenodd" d="m23.35 1.35-22 22-.7-.7 22-22zM1 3h17.5v1H2v16H1zm21 11.85V4h1v17H6v-1h16v-3.77l-3.24-3.08-4.57 4.48-2.85-2.84.7-.7 2.15 2.14 4.56-4.47 3.25 3.1ZM4 8.5a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0M6.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3" clip-rule="evenodd"/></svg>
      <span class="platzhalter-label">${esc(label)}</span>
      <span class="platzhalter-aktion">${dateiZeigen ? esc(datei) : "Foto hinzufügen"}</span>
    </div>
  </div>`;
}

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);
  /* data-bildgalerie: ein Tipp irgendwo auf die Kachel (Foto oder Platzhalter)
     schaltet zum naechsten Bild weiter - Hochladen/Ersetzen/Loeschen bleibt
     bewusst allein den Einstellungen vorbehalten (Bild der Übersicht),
     deshalb hier kein data-bildupload/data-bildklick, nur bildWeiter().
     Der Wrapper ist noetig, weil das <img> selbst bei fehlendem Foto per
     .bildbox.bild-fehlt img{display:none} unsichtbar und damit unklickbar
     ist - der bisherige Handler (id="carimg") griff dann ins Leere. */
  return `<div data-bildgalerie>
    ${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg")}
  </div>
  <div class="dots" id="dots">${BILDER.map((_, i) => `<i class="${i === bild ? "on" : ""}"></i>`).join("")}</div>`;
}
/* 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 `<span class="marke-logo" data-markelogo title="${esc(label)} (${esc(MARKENLOGO_DATEI)})">
    <img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="${esc(label)}" data-bilddatei="${esc(MARKENLOGO_DATEI)}"
         onerror="this.closest('[data-markelogo]').classList.add('logo-fehlt')">
    <span class="ph">${esc(label)}</span>
  </span>`;
}
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;
  }
  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 dedat() sonst
   "NaN.NaN.NaN" - ein Wert, der wie eine Angabe aussieht und keine ist. */
const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime());
const dedat = (d) => (gueltigesDatum(d)
  ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })
  : "–");
function dat(s) { 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 `<div class="ringwrap">
    <svg width="150" height="150" viewBox="0 0 150 150">
      <circle cx="75" cy="75" r="${r}" fill="none" stroke="var(--line)" stroke-width="2"/>
      <circle cx="75" cy="75" r="${r}" fill="none" stroke="var(--red)" stroke-width="2"
        stroke-linecap="round" stroke-dasharray="${on} ${c}" transform="rotate(-90 75 75)"/>
    </svg>
    <div class="mid">
      <div class="fig" style="font-size:40px">${pct}<span class="unit">%</span></div>
      <span class="label" style="margin-top:8px">${de(CONFIG.tankvolumen * pct / 100, 1)} Liter</span>
    </div></div>`;
}

/* ------------------------------------------------------------------ Karte
   Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */
let MAP = null, TILE = null, LEAFLET_LADEND = null;
// Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren).
let BELEG_STATION_POS = null;
/* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die
   Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen
   und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten
   dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */
const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const ATTR = "&copy; OpenStreetMap &middot; 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 &copy; Esri";

const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";

/* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head:
   Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head,
   fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen
   dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px
   hoch), von der Karte war nur ein schmaler Streifen zu sehen und der
   Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS
   gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */
function leafletCssEinhaengen() {
  if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve();
  return new Promise((fertig) => {
    const css = document.createElement("link");
    css.rel = "stylesheet";
    css.href = LEAFLET_CSS_URL;
    css.dataset.leafletCss = "1";
    /* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht
       die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */
    css.onload = fertig;
    css.onerror = fertig;
    ROOT.appendChild(css);
  });
}

function leafletLaden() {
  /* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer
     Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */
  const css = leafletCssEinhaengen();
  if (window.L) return css;
  if (!LEAFLET_LADEND) {
    LEAFLET_LADEND = new Promise((resolve, reject) => {
      const script = document.createElement("script");
      script.src = LEAFLET_JS_URL;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  return Promise.all([css, LEAFLET_LADEND]);
}

function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }

/* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf
   holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte
   herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über
   der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert -
   die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf
   (siehe adresseAufloesen). */
function mapBoxMitZentrieren() {
  return `<div class="mapbox">
    <div id="map"></div>
    <div class="kartensteuerung kartensteuerung-klein">
      <button type="button" id="belegZentrieren" aria-label="Auf die Tankstelle zentrieren" disabled>${ciSVG(CI.gps, 20)}</button>
    </div>
  </div>`;
}

/* 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 = TILE_URL;
  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") {
    BELEG_STATION_POS = null;   // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch
    const f = FILLS.find((x) => x.tank_id === route.id);
    if (f && f.station_lat != null) {
      tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f);
    } else if (f && (f.station_address || f.station_name)) {
      /* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort
         da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte
         inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen
         prüfen, ob wir noch auf demselben Beleg sind. */
      MAP.setView(kartenMittelpunkt(), 11);
      const karte = MAP, belegId = route.id;
      adresseAufloesen(f.station_address || f.station_name).then((pos) => {
        if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return;
        tankstelleAufKarteZeigen(karte, pos, f);
      });
    } 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 CI-Satz, in den beiden gelieferten
   Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter
   poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet
   am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25
   im 48er- bzw. y=23.3 im 24er-viewBox). */
function fahrzeugMarkerSVG(px = 34) {
  const gross = px >= 34;
  const box = gross ? 48 : 24;
  const sil = gross ? CI.poiCarLSil : CI.poiCarSil;
  const icon = gross ? CI.poiCarL : CI.poiCar;
  return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${sil}${icon}</svg>`;
}

/* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug,
   nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander-
   zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */
function tankstellenMarkerSVG(px = 32) {
  const gross = px >= 34;
  const box = gross ? 48 : 24;
  const sil = gross ? CI.poiLSil : CI.poiSSil;
  const icon = gross ? CI.poiL : CI.poiS;
  return `<svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true">${sil}${icon}</svg>`;
}

/* Fahrzustand fürs Menü: kommt aus der Zündung (CAR.zuendung, vom Backend aus
   demselben Sensor veröffentlicht, den auch fahrterkennung.py als maßgebliches
   Signal nimmt). Ist kein Zündungssensor zugeordnet, bleibt der Zustand
   ehrlich unbekannt, statt "steht" zu behaupten.

   Vorher stand hier `TRIPS[0].status === "offen"` - gleich zwei Fehler
   übereinander, die sich gegenseitig verstärkt haben:

     1. "offen" heißt NICHT "unterwegs", sondern "Daten noch unvollständig"
        (die Strecke fehlt, siehe fahrtabschluss_logik.py). Eine beendete
        Fahrt ohne Kilometerstand - etwa weil zu dem Zeitpunkt kein
        KM_SENSOR zugeordnet war - bleibt für immer "offen".
     2. TRIPS[0] ist die ÄLTESTE Fahrt, nicht die neueste:
        fahrten_veroeffentlichen() gibt profil.fahrten_lesen() unverändert in
        Dateireihenfolge weiter, und die ist aufsteigend.

   Zusammen hieß das: die älteste jemals unvollständig gebliebene Fahrt hat
   das Fahrzeug dauerhaft als "fahrend" angezeigt. An der Testinstanz war das
   eine Fahrt, die seit 18 Tagen beendet war.

   parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in
   "Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen
   Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner
   Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */
function letzteFahrt() {
  let neueste = null;
  for (const t of TRIPS) {
    if (!t || !t.ts_end) continue;
    if (!neueste || t.ts_end > neueste.ts_end) neueste = t;
  }
  return neueste;
}
function standortZustand() {
  const t = letzteFahrt();
  const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null;
  if (CAR.zuendung === true) return { faehrt: true, seitTs: null, parkplatz: false };
  const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null;
  return {
    faehrt: false,
    unbekannt: CAR.zuendung == null,
    seitTs,
    parkplatz: minuten != null && minuten >= (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 = "GPS offline"; standortAnsichtAktualisieren(); return; }
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy };
      USER_POS_TS = Date.now(); USER_POS_FEHLER = null;
      standortAnsichtAktualisieren();
    },
    (err) => {
      // Diese Zeile steht direkt unter dem Fahrzeugnamen, an der Stelle, wo
      // sonst der Abstand zum Auto steht - sie handelt aber vom Standort
      // DIESES Geräts, nicht vom Fahrzeug. "Standortzugriff verweigert" las
      // sich dort so, als sei der Standort des Autos nicht abrufbar.
      //
      // Die verweigerte Freigabe bleibt trotzdem eigens benannt: sie ist der
      // einzige Fall mit einer anderen Abhilfe (Freigabe erteilen statt auf
      // GPS-Empfang warten), und "GPS offline" würde dort in die falsche
      // Richtung schicken.
      USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline";
      // Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die
      // Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die
      // Freigabe verweigert - und jedes Backend-Update loeste eine neue
      // Hochgenauigkeits-Abfrage aus.
      USER_POS_TS = Date.now();
      standortAnsichtAktualisieren();
    },
    { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
  );
}
function standortErfassenFallsNoetig() {
  // Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener
  // Versuch kein Versuch, siehe Fehlerpfad oben.
  if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return;
  standortErfassen();
}

/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
   wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
   Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
   der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits
   dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
   auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an
   derselben Stelle keinen zweiten Abruf auslöst. */
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;
  });
}

/* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die
   Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift
   trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen
   Schritt bliebe die Karte dort dauerhaft ohne Nadel.

   Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse
   ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet,
   dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen
   würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu
   cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse,
   die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */
const GEO_CACHE_KEY = "audi_dashboard_geocache";
let GEO_CACHE = null;
function geoCacheLesen() {
  if (GEO_CACHE) return GEO_CACHE;
  try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; }
  catch (e) { GEO_CACHE = {}; }
  return GEO_CACHE;
}
function geoCacheSchreiben(schluessel, wert) {
  const cache = geoCacheLesen();
  cache[schluessel] = wert;
  try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); }
  catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ }
}
async function adresseAufloesen(adresse) {
  const suche = (adresse || "").trim();
  if (!suche) return null;
  const cache = geoCacheLesen();
  if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche];
  let treffer = null;
  try {
    const res = await fetch(
      `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
      { headers: { "Accept-Language": "de" } }
    );
    const daten = await res.json();
    if (Array.isArray(daten) && daten.length) {
      treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) };
    }
  } catch (e) {
    // Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der
    // nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen).
    return null;
  }
  geoCacheSchreiben(suche, treffer);
  return treffer;
}

/* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von
   initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon
   steht. */
function tankstelleAufKarteZeigen(karte, pos, f) {
  if (!karte || !window.L || !pos) return;
  karte.setView([pos.lat, pos.lon], 14);
  const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${tankstellenMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
  window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte)
    .bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
  // Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
  // Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
  // Aufrufstelle in initMap).
  BELEG_STATION_POS = pos;
  const knopf = ROOT && ROOT.getElementById("belegZentrieren");
  if (knopf) knopf.disabled = false;
}
function belegAufTankstelleZentrieren() {
  if (!MAP || !BELEG_STATION_POS) return;
  MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14));
}

function standortKartenlink() {
  return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null;
}
async function standortTeilen() {
  const url = standortKartenlink();
  if (!url) return;
  const titel = `${CONFIG.fahrzeugtitel} · Standort`;
  if (navigator.share) {
    try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ }
    return;
  }
  try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); }
  catch (e) { hinweis("Teilen nicht möglich", url); }
}

/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und
   ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */
function standortAnsichtAktualisieren() {
  if (route.name !== "standort") return;
  standortMarkerZeichnen();
  const dist = $("#standortDistanz");
  if (dist) dist.textContent = standortAbstandText();
}

function standortTiles() {
  if (!SMAP || !window.L) return;
  if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE);
  SMAP_TILE = SMAP_STIL === "satellit"
    ? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP)
    : window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
}
function standortMarkerZeichnen() {
  if (!SMAP || !window.L) return;
  if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; }
  if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; }
  if (standortBekannt()) {
    const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [19, 36] });
    FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP);
    FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen());
  }
  if (USER_POS) {
    const icon = window.L.divIcon({ className: "user-pin-wrap", html: `<div class="user-pin"></div>`, 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(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
  if (standortBekannt()) {
    TMAP.setView([CAR.standortLat, CAR.standortLon], 14);
    const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 31] });
    window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP);
  } else {
    TMAP.setView(kartenMittelpunkt(), 11);
  }
  standortErfassenFallsNoetig();
  setTimeout(() => TMAP && TMAP.invalidateSize(), 60);
}

function standortMenuElement() { return ROOT.getElementById("standortmenu"); }
function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; }
function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; }
function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); }

/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette,
   bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse,
   anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar
   Standort-Abschnitt). */
let smY0 = null, smOffenBeimStart = false;
/* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt,
   deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */
function standortMenuHub() {
  const el = standortMenuElement();
  return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0;
}
function standortMenuVerdrahten() {
  ROOT.addEventListener("pointerdown", (e) => {
    if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return;
    smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN;
    const el = standortMenuElement(); if (el) el.style.transition = "none";
  });
  ROOT.addEventListener("pointermove", (e) => {
    if (smY0 === null) return;
    e.preventDefault();
    const el = standortMenuElement(); if (!el) return;
    const dy = e.clientY - smY0;
    const hub = standortMenuHub();
    const basis = smOffenBeimStart ? 0 : hub;
    const versatz = Math.max(0, Math.min(hub, basis + dy));
    el.style.transform = `translateY(${versatz}px)`;
  }, { passive: false });
  ROOT.addEventListener("pointerup", (e) => {
    if (smY0 === null) return;
    const dy = e.clientY - smY0;
    standortMenuGesteBeenden();
    if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); }
    else standortMenuUmschalten();
  });
  /* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung
     uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der
     pointermove-Handler oben ruft dann preventDefault() fuer jede weitere
     Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein
     pointerup kommt, der dann ein dy aus einem veralteten Startpunkt
     auswertet. Die drei anderen Zeigergesten der Datei machen das laengst
     richtig. */
  ROOT.addEventListener("pointercancel", () => {
    if (smY0 === null) return;
    standortMenuGesteBeenden();
  });
}

/* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die
   CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */
function standortMenuGesteBeenden() {
  const el = standortMenuElement();
  if (el) { el.style.transition = ""; el.style.transform = ""; }
  smY0 = null;
}

function standortKachel() {
  return `<div class="tile flat standort-kachel">
    <button type="button" class="standort-kachelbtn" data-go="standort:x" aria-label="Standort in Vollbild öffnen">
      <div class="standort-vorschau" id="tmap"></div>
      ${!standortBekannt() ? `<div class="standort-leer">Kein GPS-Signal vom Fahrzeug</div>` : ""}
    </button>
  </div>`;
}

/* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die
   Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */
const CI = {
  mapLayer: '<path fill="currentColor" d="m11.7 19.4-8.97-5.66.54-.85 8.96 5.67c.17.1.37.1.54 0l8.96-5.67.54.85-8.97 5.66a1.5 1.5 0 0 1-1.6 0m.53-3.84c.17.1.37.1.54 0l8.3-5.25-8.3-5.23a.5.5 0 0 0-.54 0l-8.3 5.23zm-.53.84-9.64-6.09 9.64-6.08a1.5 1.5 0 0 1 1.6 0l9.64 6.08-9.64 6.1c-.49.3-1.11.3-1.6 0Z"/>',
  carSearch: '<path fill="currentColor" d="M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z"/>',
  gps: '<path fill="currentColor" d="M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z"/>',
  poiCar: '<path fill="currentColor" d="M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z"/>',
  /* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die
     Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */
  /* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die
     Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug
     vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu
     unterscheiden). Siehe tankstellenMarkerSVG(). */
  poiL: '<path fill="currentColor" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M16 17.5a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0m7.5-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13"/>',
  poiS: '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5m7.5 14-.41.28.41.6.41-.6zm0-.89a92 92 0 0 0 3.07-4.9c.87-1.5 1.73-3.1 2.38-4.58A10.4 10.4 0 0 0 18 8.5C18 4.88 15.12 2 11.5 2A6.46 6.46 0 0 0 5 8.5c0 .86.4 2.14 1.05 3.63a48 48 0 0 0 2.38 4.58 91 91 0 0 0 3.07 4.9M8 8.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0M11.5 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"/>',
  poiCarL: '<path fill="currentColor" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z"/>',
  /* Vollton-Silhouetten fuer die Kartennadeln: poi-car/poi sind reine Kontur-
     formen (Ring aus zwei gegenlaeufigen Randlinien + duenne Detaillinien im
     Inneren) - eine Pixelprobe ergab nur 13-40% gefuellte Flaeche und war auf
     Kartenkacheln praktisch unsichtbar (Nutzer-Feedback "Pin ist transparent/
     durchsichtig"). Jede Silhouette ist exakt das erste Teilpfad-Segment des
     jeweiligen CI-Pfads oben (die aeussere Ballonform, vor dem Punkt, der die
     Ring-Innenlinie startet) - keine Neuzeichnung, nur der bereits im CI-Satz
     enthaltene Umriss, mit fester Farbe statt currentColor gefuellt. Wird in
     fahrzeugMarkerSVG()/tankstellenMarkerSVG() als unterste Ebene hinter dem
     Original-Icon (dann weiss statt dunkel) gerendert. */
  poiCarLSil: '<path fill="#182634" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5"/>',
  poiCarSil: '<path fill="#182634" d="M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6"/>',
  poiLSil: '<path fill="#182634" d="M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5"/>',
  poiSSil: '<path fill="#182634" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5"/>',
  selectAll: '<path fill="currentColor" d="M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z"/>',
  planRoute: '<path fill="currentColor" d="m18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z"/>',
  upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>',
  fuelRange: '<path fill="currentColor" d="M2 3.5A2.5 2.5 0 0 1 4.5 1h6A2.5 2.5 0 0 1 13 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H13v7h-1V3.5c0-.83-.67-1.5-1.5-1.5h-6C3.67 2 3 2.67 3 3.5V22h4v1H1v-1h1zM4 10h7v1H4zm5 14v-5h1v2h13v-2h1v5h-1v-2H10v2z"/>',
  parking: '<path fill="currentColor" d="M0 2.5A2.5 2.5 0 0 1 2.5 0H23v20.5a2.5 2.5 0 0 1-2.5 2.5H0zM2.5 1C1.67 1 1 1.67 1 2.5V22h19.5c.83 0 1.5-.67 1.5-1.5V1zm11.59 6.35C13.6 7 13.02 7 12.5 7H8v6h4.5c.52 0 1.11-.01 1.59-.35.44-.3.91-1 .91-2.65 0-1.66-.47-2.34-.91-2.65M8 14v3H7V6h5.53c.5 0 1.38 0 2.13.53C15.47 7.09 16 8.16 16 10s-.53 2.9-1.34 3.47a3.6 3.6 0 0 1-2.13.53z"/>',
  /* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
     verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
  battery12vL: '<path fill="currentColor" d="M11 11h7v4h13v-4h7v4h6v23H5V15h6zm1 4h5v-3h-5zm-6 1v21h37V16zm31-1v-3h-5v3zm-3 11v4.5h-1V26h-4.5v-1H33v-4.5h1V25h4.5v1zm-15.5 0h-8v-1h8z"/>',
  battery12vS: '<path fill="currentColor" d="M5 5h4v2h6V5h4v2h3v12H2V7h3zm1 2h2V6H6zM3 8v10h18V8zm15-1V6h-2v1zm-1 6v2h-1v-2h-2v-1h2v-2h1v2h2v1zm-8 0H5v-1h4z"/>',
  /* edit-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
     verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
  editL: '<path fill="currentColor" d="M28.99 3.6c.34-.1.73-.11 1.1.04h.02l3.61 1.81c.35.18.58.5.68.84s.11.73-.04 1.1v.01l-2.01 4.06 4.02 2.02-6.72 13.44-.9-.44 6.28-12.56-3.13-1.56-13.6 27.47-6.3 4.66v-7.8L28.15 4.27c.18-.35.5-.58.84-.68Zm2.46 7.42 2-4.02a.65.65 0 0 0 0-.41c-.05-.14-.12-.22-.17-.24l-3.58-1.8a.65.65 0 0 0-.41 0c-.14.05-.22.12-.24.17L27.02 8.8zm-4.88-1.33-13.4 26.89 4.5 2.25L31.01 11.9l-4.44-2.2ZM13 37.61v4.9l3.95-2.93z"/>',
  editS: '<path fill="currentColor" d="m14.14 1.06 2.4 1.1.04.02c.23.16.42.39.52.65.1.27.13.6-.06.91l-.91 1.94 2.94 1.38-2.82 5.95-.9-.42 2.38-5.05-2.02-.95L9.5 19.8 5 23.57v-5.88l7.56-16.14.02-.03c.16-.23.39-.42.65-.52a1 1 0 0 1 .9.06Zm1.08 4.2.94-2 .02-.03v-.04a.4.4 0 0 0-.13-.16l-2.4-1.1-.02-.01h-.04a.4.4 0 0 0-.16.13L12.53 4l2.7 1.27Zm-3.12-.37L6.16 17.56l2.7 1.27L14.8 6.16 12.1 4.9ZM6 18.6v2.84l2.18-1.81L6 18.59Z"/>',
  /* oil-change-l/-s, inspection-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als
     SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie
     poi-l/poi-s. Die -s-Variante steht links neben der Kennzahl im
     Nächster-Service-Block (vHome()) statt des vorherigen "bis zum/zur..."
     Textes - die Serviceart wird jetzt ueber das Icon erkannt, nicht ueber
     Worte. */
  oilChangeL: '<path fill="currentColor" d="M15 16a.5.5 0 0 0-.5.5c0 .28.22.5.5.5h5a.5.5 0 0 0 .5-.5.5.5 0 0 0-.5-.5zm-1.5.5c0-.83.66-1.5 1.5-1.5h5a1.5 1.5 0 1 1 0 3h-2v3h5.67l3.92 2.94 16.45-5.48 3.72 1.86-2.11 2.81-2.17-1.08-9.65 3.86L27.77 35H9V24.73L5.82 22H1v-3.5h6.16l3.5 2.5H17v-3h-2a1.5 1.5 0 0 1-1.5-1.5M2 19.5V21h4.18L10 24.27V34h17.23l5.94-8.9 10.35-4.15 1.83.92.89-1.2-2.28-1.13-16.55 5.52L23.33 22H10.34l-3.5-2.5zm43.5 4.1.42.62-.42.28-.42-.28zm0 1.84a17 17 0 0 0-.8 1.55c-.14.34-.2.59-.2.73 0 .8.52 1.28 1 1.28s1-.49 1-1.28c0-.14-.06-.39-.2-.73a10 10 0 0 0-.8-1.55m0-.94.42-.28v.01l.01.02.02.03.03.05a18 18 0 0 1 1.25 2.29c.14.36.27.76.27 1.1 0 1.18-.82 2.28-2 2.28s-2-1.1-2-2.28c0-.34.13-.74.27-1.1.15-.38.35-.77.54-1.12a19 19 0 0 1 .76-1.25v-.02h.01z"/>',
  oilChangeS: '<path fill="currentColor" d="M9 8H7.5V7h4v1H10v2h2.77l.94 1.41 7.93-2.48 2.21 2.22-.7.7-1.79-1.78-1.03.32L15.25 17H4v-6.8L2.8 9H0V8h3.2l2 2H9zm-4 3v5h9.75l3.92-5.1-5.38 1.69L12.23 11z"/>',
  inspectionL: '<path fill="currentColor" d="M11.5 1.8 13 3.3v4l.7.7h4.6l.7-.7v-4l1.5-1.5L25 6.3v6.4l-5 5v24.8c0 1.98-1.52 3.5-3.5 3.5h-1a3.45 3.45 0 0 1-3.5-3.5V17.7l-5-5V6.3zm21.2.2h4.6L40 6.87v4.84l-3 3L36.9 26H40v3.12l-1 2v5.26l1 2v5.12a2.5 2.5 0 0 1-2.5 2.5h-5a2.5 2.5 0 0 1-2.5-2.5v-5.12l1-2v-5.26l-1-2V26h3V14.7l-3-3V6.88zm3.2 23.9.1-11.6 3-3V7.12L36.7 3h-3.4L31 7.13v4.16l3 3V25.9zM8 6.7v5.6l5 5v25.2c0 1.42 1.08 2.5 2.5 2.5h1c1.42 0 2.5-1.08 2.5-2.5V17.3l5-5V6.7l-3.5-3.5-.5.5v4L18.7 9h-5.4L12 7.7v-4l-.5-.5zM31 27v1.88l1 2v5.74l-1 2v4.88c0 .82.68 1.5 1.5 1.5h5c.82 0 1.5-.68 1.5-1.5v-4.88l-1-2v-5.74l1-2V27z"/>',
  inspectionS: '<path fill="currentColor" d="M3.3 0H5v3.3l.7.7h2.6l.7-.7V0h1.7L13 2.3v4.4l-3 3V21c0 .69-.3 1.42-.76 1.98A2.57 2.57 0 0 1 7.3 24h-.6c-.79 0-1.47-.42-1.93-.98A3.22 3.22 0 0 1 4 21V9.7l-3-3V2.3zm.4 1L2 2.7v3.6l3 3V21c0 .47.2.98.53 1.38.34.4.76.62 1.17.62h.6c.4 0 .82-.24 1.16-.65.34-.42.54-.94.54-1.35V9.3l3-3V2.7L10.3 1H10v2.7L8.7 5H5.3L4 3.7V1zm12.44-1h3.72L21 3.42V6.7l-1 1V14h1v7.5a2.5 2.5 0 0 1-2.5 2.5h-1a2.5 2.5 0 0 1-2.5-2.5V14h1V7.7l-1-1V3.43zM16 15v6.5c0 .82.68 1.5 1.5 1.5h1c.82 0 1.5-.68 1.5-1.5V15zm3-1V7.3l1-1V3.57L19.14 1h-2.28L16 3.58V6.3l1 1V14z"/>',
};
function ciSVG(pfad, px = 16) {
  return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">${pfad}</svg>`;
}

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 `
  <div class="standort-vollbild">
    <div id="smap" class="standort-karte"></div>
    <div class="kartensteuerung">
      <button type="button" data-standort-layer aria-pressed="${SMAP_STIL === "satellit"}"
        aria-label="${SMAP_STIL === "satellit" ? "Satellitenansicht, zur Straßenkarte wechseln" : "Straßenkarte, zur Satellitenansicht wechseln"}">
        ${ciSVG(CI.mapLayer, 22)}
      </button>
      <button type="button" data-standort-fahrzeug aria-label="Fahrzeug auf der Karte suchen">
        ${ciSVG(CI.carSearch, 22)}
      </button>
      <button type="button" data-standort-user aria-label="Auf eigenen Standort zentrieren">
        ${ciSVG(CI.gps, 22)}
      </button>
      <button type="button" data-standort-beide aria-label="Fahrzeug und eigenen Standort gemeinsam zeigen">
        ${ciSVG(CI.selectAll, 22)}
      </button>
    </div>
    <div class="standortmenu" id="standortmenu">
      <div class="standortmenu-griffzone">
        <!-- Als Knopf, nicht als reines div: sonst laesst sich das Menue
             ausschliesslich per Wischgeste oder Klick auf den Kartenmarker
             oeffnen und ist mit Tastatur gar nicht erreichbar. -->
        <button type="button" class="standortmenu-griff" data-standort-umschalten
          aria-expanded="${STANDORTMENU_OFFEN}" aria-controls="standortmenu"
          aria-label="Fahrzeugdetails ${STANDORTMENU_OFFEN ? "einklappen" : "aufklappen"}"></button>
        <button type="button" class="standortmenu-schliessen" data-standort-schliessen aria-label="Schließen">
          <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5 5l10 10M15 5 5 15"/></svg>
        </button>
        <div class="standortmenu-kopf">
          <div class="standortmenu-name">${esc(CONFIG.fahrzeugtitel)}</div>
          <div class="standortmenu-distanz" id="standortDistanz">${distanz}</div>
        </div>
      </div>
      <div class="standortmenu-adresse" id="standortAdresse">${esc(adresseText)}</div>
      <div class="standortmenu-status">
        ${zustand.faehrt
          ? `<span class="dot"></span><span>Fahrzeug fährt</span>`
          : zustand.unbekannt
            ? `${ciSVG(CI.parking)}<span>Fahrzustand unbekannt</span>`
            : `${ciSVG(CI.parking)}<span>${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}</span>`}
      </div>
      <div class="standortmenu-werte">
        ${ciSVG(CI.fuelRange)}
        <span>${de(CAR.tankPct)} %</span>
        <span>${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"}</span>
      </div>
      <div class="standortmenu-aktionen">
        <a class="standort-pille primaer"${link ? ` href="${link}" target="_blank" rel="noopener"` : ` aria-disabled="true" style="pointer-events:none;opacity:.4"`}>
          ${ciSVG(CI.planRoute, 15)}Route
        </a>
        <button type="button" class="standort-pille" data-standort-teilen ${bekannt ? "" : "disabled"}>
          ${ciSVG(CI.upload, 15)}Teilen
        </button>
      </div>
    </div>
  </div>`;
}

/* -------------------------------------------------------------- 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 `<div class="badge">
    <img class="positive" src="${STATIK}/badges/${datei}-positive.svg" alt="${alt}">
    <img class="negative" src="${STATIK}/badges/${datei}-negative.svg" alt="${alt}">
    ${zusatz ? `<span class="zusatz">${esc(zusatz)}</span>` : ""}</div>`;
}

function teaser() {
  if (!TRIPS.length && !FILLS.length) return "";
  const t = TRIPS[0], f = FILLS[0];
  return `<div class="tile"><span class="label">Zuletzt</span>
    ${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
      <span class="k">Letzte Fahrt<small>${esc(t.start_address || "")} → ${esc(t.end_address || "")}</small></span>
      <span class="v">${t.distance_km != null ? de(t.distance_km) + " km" : "offen"}<small>${t.status}</small></span>
      <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
    ${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
      <span class="k">Letzter Tankvorgang<small>${esc(f.station_name || "")}</small></span>
      <span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : ""}</small></span>
      <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}</div>`;
}

// Kandidaten ohne ziel (kein Servicebucheintrag, keine Fahrzeugmeldung, bei
// der Hauptuntersuchung auch keine verwertbare Erstzulassung) müssen VOR dem
// Sortieren raus: (a,b) => a.ziel - b.ziel zwingt null auf 0 (Epoch 1970) und
// liess einen datenlosen Termin dadurch immer als "naechster" gewinnen -
// selbst gegen einen anderen Termin mit echtem, berechnetem Datum. null nur
// dann zurückgeben, wenn wirklich kein einziger Termin ein ziel hat.
function naechsterTermin() {
  const mitZiel = termine().filter((t) => t.ziel !== null);
  return mitZiel.length ? mitZiel.slice().sort((a, b) => a.ziel - b.ziel)[0] : null;
}

// CI-Icon je Serviceart statt eines Wort-Zusatzes ("bis zum Ölwechsel" o.ä.) -
// nur Ölwechsel/Inspektion haben eines, die Hauptuntersuchung nicht. Verwendet
// von vHome() (Nächster-Service-Block) und vService() (Anstehende Termine).
// Das Ölwechsel-Icon ist bei gleicher Boxgröße optisch kleiner als das
// Inspektion-Icon (füllt in seinem eigenen 24x24-Raster nur eine 24x10-Fläche
// statt fast der vollen Höhe) - deshalb eigene, größere Boxgröße statt einer
// gemeinsamen Konstante.
const ART_ICON = { "Ölwechsel": CI.oilChangeS, "Inspektion": CI.inspectionS };
const ART_ICON_GROESSE = { "Ölwechsel": 26, "Inspektion": 20 };

function intervalle(t) {
  const oel = CAR.oel, b = t.basis;
  if (!b) return null;
  const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) });
  if (t.art === "Hauptuntersuchung") {
    const eig = bauen(null, 24);
    return { hersteller: null, eigen: eig, prognose: eig.datum };
  }
  const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000;
  const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24;
  const eig = bauen(eKm, eM);
  // "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten"
  // tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist
  // "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten
  // immer die Herstellerwerte zeigen und das Kundenintervall ignorieren.
  const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24);
  const perKm = prognose(eig.km).datum;
  return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum };
}
/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe
   einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete
   Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht:
   die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den
   selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu
   nichts meldet (Sensor fehlt/nicht verfügbar). */
function fahrzeugMeldungRoh(art) {
  if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm };
  if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm };
  return { ts: null, km: null };
}

/* App-eigene Service-Prognose, eigenständig statt über die allgemeine
   tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie
   den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit
   einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum
   der letzten tatsächlich durchgeführten Arbeit dieser Art.

   Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle
   Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum
   eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das
   Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither
   wenig gefahren wurde.

   Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden
   Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */
function servicePrognose(stichwort, intervallKm, intervallMonate) {
  if (!CAR.odoBekannt) return null;
  const letzter = letzterEintrag(stichwort);
  if (!letzter) return null;
  const zielKm = letzter.km + intervallKm;
  const restKm = Math.max(0, zielKm - CAR.odo);
  const kappDatum = monatePlus(letzter.datum, intervallMonate);
  let datum;
  if (restKm <= 0) {
    datum = heute();
  } else {
    const tageSeit = (heute() - dat(letzter.datum)) / dTag;
    const kmSeit = CAR.odo - letzter.km;
    if (tageSeit <= 0 || kmSeit <= 0) {
      // Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B.
      // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das
      // Zeitlimit als einzig belastbare Grenze zeigen.
      datum = kappDatum;
    } else {
      const kmProTagSeither = kmSeit / tageSeit;
      const tageBisZiel = restKm / kmProTagSeither;
      const berechnet = new Date(heute().getTime() + tageBisZiel * dTag);
      datum = berechnet < kappDatum ? berechnet : kappDatum;
    }
  }
  return { km: zielKm, restKm, datum };
}

/* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), unabhängig
   vom Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz
   ohne Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
   bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
   erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
   einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
function kmProTagAusFahrten() {
  const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null);
  if (!gueltig.length) return null;
  const km = gueltig.reduce((s, t) => s + t.distance_km, 0);
  const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime()));
  const tage = (heute().getTime() - aeltesteZeit) / dTag;
  return tage >= 1 ? km / tage : null;
}

/* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
   Servicebucheintrag (siehe Aufrufer in vService()) - ein echter
   Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
   Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
   Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
   auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze - fährt der
   Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die
   gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */
function meldungsPrognose(art) {
  const fm = fahrzeugMeldungRoh(art);
  const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
  if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null;
  const restKm = Math.abs(fm.km);
  if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm };
  const rate = kmProTagAusFahrten();
  if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null;
  const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
  const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
  return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm };
}

/* Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die
   Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in
   "Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service
   wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall-
   Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und
   das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet -
   eine Näherung (der wahre letzte Service kann näher oder ferner liegen),
   aber ohne sie würde die eigene Intervallwahl bis zum ersten
   Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */
function meldungsPrognoseEigenesIntervall(fm, herstellerKm, herstellerMonate, eigenKm, eigenMonate) {
  if (fm.km == null || !herstellerKm || eigenKm == null) return null;
  const restKm = Math.max(0, Math.abs(fm.km) - herstellerKm + eigenKm);
  let zeitDatum = null;
  if (fm.ts && gueltigesDatum(new Date(fm.ts)) && herstellerMonate != null && eigenMonate != null) {
    zeitDatum = new Date(fm.ts);
    zeitDatum.setMonth(zeitDatum.getMonth() + (eigenMonate - herstellerMonate));
  }
  if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm };
  const rate = kmProTagAusFahrten();
  if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null;
  const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
  const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
  return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm };
}

/* Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei
   Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe), bei
   eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall()
   hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne
   Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst (kein
   Sensor dafür), fällt das sanft auf die reine Meldung zurück. */
function oelMeldungsPrognose() {
  const oel = CAR.oel;
  if (oel.modus === "hersteller") return meldungsPrognose("Ölwechsel");
  const fm = fahrzeugMeldungRoh("Ölwechsel");
  const eigen = meldungsPrognoseEigenesIntervall(fm, oel.herstellerKm, oel.herstellerMonate, oel.km, oel.monate);
  return eigen || meldungsPrognose("Ölwechsel");
}

function oelwechselPrognose() {
  const oel = CAR.oel;
  return servicePrognose("öl",
    oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm),
    oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate));
}
/* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine().
   Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die
   Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */
function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); }

/* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und
   Service-Seite identisch aufgebaut. Leer, solange kein passender
   Servicebucheintrag vorliegt. */
function prognoseZeile(art) {
  const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null;
  if (!p) return "";
  const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : "";
  return `<br><span style="color:var(--fg3);font-size:11px">${km}voraussichtlich am ${dedat(p.datum)}</span>`;
}

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 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 `
  <div class="szene bleed ${szeneKlasse(startIndex())}"><div class="carbox">${carImg("fest")}</div></div>
  <div class="tile flat" style="margin-bottom:20px">
    ${badge()}
    <span class="label" style="margin-top:10px">${esc(CONFIG.kennzeichen)}</span>
    <button class="status" data-go="sicherheit:x">
      <span class="dot ${statusDot}"></span>
      <div><div class="t">${statusText}</div></div>
      <svg class="chev" viewBox="0 0 6 10" style="margin-left:auto"><polyline points="1,1 5,5 1,9"/></svg>
    </button>
  </div>
  <div class="tile flat" style="margin-bottom:22px">
    <div class="tankzeile">
      <div class="fig" style="font-size:60px">${CAR.reichweite != null ? de(CAR.reichweite) : "–"}<span class="unit">km</span></div>
      <span class="pct">${CAR.tankBekannt ? pct + " %" : "–"}</span>
    </div>
    <div class="balken"><i style="width:${pct}%;background:var(--fg)"></i></div>
    <span class="label" style="margin-top:12px">Reichweite</span>
  </div>
  ${(() => {
    /* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich große
       Kacheln nebeneinander - gleiches Maß, gleiche Zahlengröße, gleiche Farbe,
       obwohl nur eine davon ein Knopf war und die beiden Zahlen Grundver-
       schiedenes meinen: der Kilometerstand ist ein gemessener Ist-Wert, der
       sich kaum ändert, der Service-Wert ein Countdown, der täglich kleiner
       wird. Zwei gleich große Zahlen nebeneinander behaupteten damit eine
       Gleichrangigkeit, die inhaltlich nicht besteht.

       Jetzt: eine führende Kennzahl, darunter der Kilometerstand als ruhige
       Wertezeile, getrennt durch eine Haarlinie - Hierarchie über Größe und
       Farbe (Audi-CI) kombiniert mit Apple HIGs "differentiate controls from
       content": der Chevron sitzt nur am Serviceblock, damit erkennbar bleibt,
       welcher Teil auf die Service-Seite führt und welcher reine Anzeige ist.
       Beides zusammen in einer normalen .tile mit Hintergrund - die Haarlinie
       trennt dann innerhalb der Kachel, statt die Kachel zu ersetzen. */
    const kmZeile = `<div class="servicezeile">
        <span class="label">Kilometerstand</span>
        <div class="fig" style="font-size:20px">${CAR.odoBekannt ? de(CAR.odo) : "–"}<span class="unit">km</span></div>
      </div>`;
    if (!t) return `<div class="tile" style="margin-bottom:22px">
        <span class="label">Nächster Service:</span>
        <div class="wert" style="margin-top:8px">kein Eintrag im Servicebuch</div>
        ${kmZeile}
      </div>`;
    // naechsterTermin() waehlt bereits ueber alle drei Arten hinweg (Oelwechsel,
    // Inspektion, Hauptuntersuchung) den zeitlich naechsten aus.
    const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null;
    const datum = t.kmDatum || t.datum;
    const hauptwert = restKm !== null
      ? `${de(restKm)}<span class="unit">km</span>`
      : (datum ? dedat(datum) : "–");
    // Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch
    // jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027"
    // auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px
    // weiterhin einzeilig bleibt).
    const zahlGroesse = 40;
    // Icon statt "bis zum Ölwechsel"/"bis zur Inspektion" (nur fuer die beiden
    // Arten, die eines haben). Die Hauptuntersuchung hat kein Restkilometer-Ziel
    // und zeigt stattdessen den Art-Namen selbst unter dem Datum.
    const artIcon = ART_ICON[t.art];
    const nebenzeile = restKm !== null && datum
      ? `voraussichtlich am ${dedat(datum)}`
      : t.art;
    const figMarkup = artIcon
      ? `<div class="fig-zeile">${ciSVG(artIcon, 31)}<div class="fig" style="font-size:${zahlGroesse}px">${hauptwert}</div></div>`
      : `<div class="fig" style="font-size:${zahlGroesse}px;margin-top:8px">${hauptwert}</div>`;
    return `<div class="tile" style="margin-bottom:22px">
        <button class="serviceblock" data-go="service:x">
          <span class="label">Nächster Service:</span>
          <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
          ${figMarkup}
          <span class="label" style="margin-top:8px">${nebenzeile}</span>
        </button>
        ${kmZeile}
      </div>`;
  })()}
  ${standortKachel()}
  ${teaser()}`;
}

/* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt)
   statt eines einfarbigen Punkts - Zustand ist so schon am Symbol selbst
   erkennbar, nicht nur an der Farbe. Nur für die Sicherheit-Ansicht (vSicherheit()/
   vTuerenKlappen()) gedacht, der übrige Bestand behält den einfachen .dot. */
function statusKreis(ok) {
  const klasse = ok === null ? "warn" : ok ? "" : "bad";
  const glyph = ok === null
    ? '<path d="M5.7 6a2.3 2.3 0 1 1 2.7 2.27c-.5.09-.7.5-.7 1.03v.2M8 11.6v.01" stroke="#fff" stroke-width="1.5" stroke-linecap="round" fill="none"/>'
    : ok
      ? '<path d="M3 8.3 6.2 11.5 13 4.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
      : '<path d="M4 4l8 8M12 4l-8 8" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>';
  return `<span class="statuskreis ${klasse}" aria-hidden="true"><svg viewBox="0 0 16 16" width="13" height="13">${glyph}</svg></span>`;
}

/* Gruppiert die flache CAR.sicherheitscheck-Liste (siehe _sicherheitscheck()
   in veroeffentlichung.py) nach ihrem "gruppe"-Feld. ok ist null, sobald ein
   einziger Punkt der Gruppe unbekannt ist, oder wenn die Gruppe ganz ohne
   Einträge dasteht (kein Sensor dieser Rolle im Setup-Menü zugeordnet) -
   nie true durch eine leere Liste erschlichen. */
function sicherheitsGruppe(gruppe) {
  const eintraege = CAR.sicherheitscheck.filter((e) => e.gruppe === gruppe);
  const ok = eintraege.length === 0 || eintraege.some((e) => e.ok === null)
    ? null
    : eintraege.every((e) => e.ok);
  return { eintraege, ok };
}

function vSicherheit() {
  const g = CAR.gesichert;
  const verriegelt = sicherheitsGruppe("verriegelt");
  const tuerenKlappen = sicherheitsGruppe("tueren_klappen");
  const fensterDach = sicherheitsGruppe("fenster_dach");
  const licht = sicherheitsGruppe("licht");
  const sammelText = g === null ? "Zustand nicht vollständig bekannt" : g ? "Fahrzeug ist sicher abgestellt" : "Bitte prüfen";
  const zeile = (label, ok, labelBad, labelWarn, route) => {
    const text = ok === null ? labelWarn : ok ? label : labelBad;
    const klickbar = !!route;
    return `<${klickbar ? "button" : "div"} class="row sicherheitszeile"${klickbar ? ` data-go="${route}:x"` : ""}>
      ${statusKreis(ok)}
      <span class="sicherheitszeile-text">${esc(text)}</span>
      ${klickbar ? '<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>' : ""}
    </${klickbar ? "button" : "div"}>`;
  };
  /* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen -
     dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
  return `
  <div class="szene bleed"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
  <div class="tile" style="display:flex;align-items:center;gap:12px;padding:16px 20px">
    ${statusKreis(g)}
    <span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
  </div>
  <div class="tile sicherheitsliste">
    ${zeile("Fahrzeug verriegelt", verriegelt.ok, "Fahrzeug nicht verriegelt", "Verriegelung unbekannt")}
    ${zeile("Türen und Klappen geschlossen", tuerenKlappen.ok, "Türen oder Klappen offen", "Türen/Klappen unbekannt", "tuerenklappen")}
    ${zeile("Fenster und Dach geschlossen", fensterDach.ok, "Fenster oder Dach offen", "Fenster/Dach unbekannt")}
    ${zeile("Licht ausgeschaltet", licht.ok, "Licht eingeschaltet", "Licht unbekannt")}
  </div>`;
}

/* Detailansicht zu "Türen und Klappen geschlossen" - jede Tür einzeln,
   Motorhaube, Kofferraum. Einzige der vier Sammelzeilen mit eigener
   Detailseite (siehe vSicherheit()), im Stil der früheren flachen
   "Geprüfte Punkte"-Liste. */
function vTuerenKlappen() {
  const eintraege = sicherheitsGruppe("tueren_klappen").eintraege;
  return `
  <div class="tile"><span class="label">Türen und Klappen</span>
    <dl class="rows" style="margin-top:10px">
      ${eintraege.length ? eintraege.map((e, i, arr) => `<div class="row" ${i === arr.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt>${esc(e.label)}</dt>
        <dd style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
          <span class="dot ${e.ok === null ? "warn" : e.ok ? "" : "bad"}"></span>
          ${e.ok === null ? "unbekannt" : e.ok ? "zu" : "offen"}
        </dd>
      </div>`).join("") : `<div class="row" style="border-bottom:0"><dt style="color:var(--fg3)">Keine Daten verfügbar</dt></div>`}
    </dl>
  </div>`;
}

function vAudi() {
  const r = CAR.reifen, a = r[r.aktiv.toLowerCase()];
  return `
  <div class="szene ${szeneKlasse(bild)}" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
  <div class="tile flat" style="margin:14px 0 16px">
    ${badge()}
    <span class="label" style="margin-top:12px">${esc(CONFIG.kennzeichen)}</span>
  </div>
  <button class="tile tilebtn" data-go="ident:x">
    <span class="label">Fahrzeug</span>
    <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>Fahrgestellnummer</dt><dd>${esc(CAR.fin)}</dd></div>
      <div class="row"><dt>Details</dt><dd>${esc(CAR.details)}</dd></div>
      <div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
      <div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
      <div class="row" style="border-bottom:0"><dt>Ausstattung</dt>
        <dd>${CAR.ausstattung.reduce((n, g) => n + g.sonder.length, 0)} Positionen</dd></div>
    </dl></button>
  <div class="tile"><span class="label">Zustand</span><dl class="rows">
    <div class="row"><dt>Kilometerstand</dt><dd>${CAR.odoBekannt ? de(CAR.odo) + " km" : "unbekannt"}</dd></div>
    <div class="row"><dt>Tankfüllung</dt><dd>${CAR.tankPct} % / ${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l</dd></div>
    <div class="row" style="border-bottom:0;cursor:pointer" data-go="battverlauf:x"><dt>Batteriespannung</dt>
      <dd style="display:flex;align-items:center;gap:6px;justify-content:flex-end">${CAR.batteriespannung != null ? de(CAR.batteriespannung, 1) + " V" : "unbekannt"}
        <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></dd></div></dl></div>
  <button class="tile tilebtn" data-go="service:x">
    <span class="label">Service</span>
    <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
    <dl class="rows" style="margin-top:12px">
      ${termine().map((t, i, arr) => {
        const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : "";
        // Hauptuntersuchung meldet das Fahrzeug nie (§9-Fallback bleibt hier
        // die einzige Quelle) - für Ölwechsel/Inspektion zeigt diese Kachel
        // bewusst NUR den realen, vom Fahrzeug gemeldeten Wert an (kein
        // Forecast, kein "Fahrzeug meldet"-Präfix mehr); fehlt die Meldung
        // (Sensor nicht verfügbar), fällt sie auf dieselbe App-eigene
        // Servicebuch-Schätzung zurück wie Hauptuntersuchung.
        const fm = t.art === "Hauptuntersuchung" ? { ts: null, km: null } : fahrzeugMeldungRoh(t.art);
        // Zusätzlich zur realen Meldung klein die App-eigene Prognose
        // (Servicebuch + tatsächliche Fahrleistung seit der letzten Arbeit,
        // siehe servicePrognose()). Steht links unter dem Kachel-Titel statt
        // rechts bei "am"/"in", die beide gleichwertig (gleiche Schriftgröße
        // wie "am") untereinander in der rechten Spalte stehen. Die Inspektion
        // bekommt sie auf Wunsch auch ohne Fahrzeugmeldung und ohne den
        // km-Anteil - beim Ölwechsel stünde die Zahl dort doppelt, weil
        // terminText() den generischen Termin schon "voraussichtlich" nennt.
        const eigen = prognoseZeile(t.art);
        if (fm.ts == null && fm.km == null) {
          const x = terminText(t);
          return `<div class="row" ${letzte}>
            <dt>${t.art}${t.art === "Inspektion" ? eigen : ""}</dt>
            <dd>${x.haupt}${x.neben ? `<small style="text-align:left">${x.neben}</small>` : ""}</dd></div>`;
        }
        const haupt = fm.ts ? dedat(new Date(fm.ts)) : "";
        const neben = fm.km != null ? "in " + de(fm.km) + " km" : "";
        return `<div class="row" ${letzte}>
          <dt>${t.art}${eigen}</dt>
          <dd>${haupt}${neben ? `<span style="display:block">${neben}</span>` : ""}</dd></div>`;
      }).join("")}
    </dl></button>
  <button class="tile tilebtn" data-go="vers:x">
    <span class="label">Versicherung/Steuer</span>
    <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>Versicherung</dt><dd>${eur(CAR.versicherung.beitrag)} €<small>im Jahr</small></dd></div>
      <div class="row"><dt>Kfz-Steuer</dt><dd>${eur(CAR.steuer.betrag)} €<small>${CAR.steuer.zeitraum === "jährlich" ? "im Jahr" : esc(CAR.steuer.zeitraum)}</small></dd></div>
      <div class="row" style="border-bottom:0"><dt>Summe</dt>
        <dd>${eur(CAR.versicherung.beitrag + CAR.steuer.betrag)} €</dd></div>
    </dl></button>
  <button class="tile tilebtn" data-go="reifen:x">
    <span class="label">Reifen · ${r.aktiv} montiert</span>
    <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>${esc(a.marke)} ${esc(a.modell)}</dt><dd>${esc(a.mass)}</dd></div>
      <div class="row"><dt>Laufleistung</dt><dd>${a.km != null ? de(a.km) + " km" : "unbekannt"}</dd></div>
      <div class="row" style="border-bottom:0"><dt>Wechseltermin</dt>
        <dd>${dedat(new Date(r.wechsel.datum))}<small>${r.wechsel.vereinbart ? "vereinbart" : "offen"}</small></dd></div>
    </dl></button>`;
}

/* ------------------------------------------------- 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, bvYDomain = null;
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
let bvGestStart = null;     // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
let bvTapKandidat = null, bvBewegtSeit = false;
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. Die Grenze
   liegt bei 12,8 V (voller Ruhezustand laut obiger Recherche) statt z. B.
   13,2 V, weil alles darüber bereits vom Generator stammt, nicht von der
   Batterie selbst - solche Werte würden den Ladezustand künstlich zu gut
   aussehen lassen. */
const AGM_RUHE_MAX_V = 12.8;
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;
  // Y-Achse einmalig aus allen Punkten festlegen, nicht bei jedem Zoom/Pan
  // neu aus den gerade sichtbaren - sonst "streckt" sich die Achse bei jeder
  // Geste neu auf den jeweils sichtbaren Min/Max-Ausschnitt.
  if (genug) {
    let yMin = Math.min(...bvPunkte.map((p) => p.v)), yMax = Math.max(...bvPunkte.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;
    bvYDomain = [yMin - pad, yMax + pad];
  } else {
    bvYDomain = 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 `
  <div class="tile">
    <span class="label">Verlauf</span>
    ${genug ? `
    <div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
      <svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;height:170px;display:block"></svg>
    </div>
    <div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
      <span class="label" id="bvRange" style="margin-top:0"></span>
      <button class="aktion" data-bvreset style="padding:8px 16px;font-size:11px;width:auto;margin-top:0">Zoom zurücksetzen</button>
    </div>
    <div id="bvTooltip" style="display:none;margin-top:12px;font-size:13px;color:var(--fg2)"></div>
    ` : `
    <span class="label" style="margin-top:14px;color:var(--fg3)">${leerText}</span>
    `}
    <div class="label bv-skala-kopf" style="margin-top:16px">
      ${ciSVG(CI.battery12vS, 15)}
      <span>Ruhespannung</span>
    </div>
    <div style="margin-top:8px;height:8px;border-radius:var(--r-pill);
      background:linear-gradient(to right, ${punktFarbe("bad")}, ${punktFarbe("warn")}, ${punktFarbe("ok")})"></div>
    <div style="display:flex;justify-content:space-between;margin-top:6px">
      <small style="color:var(--fg3)">11,6 V · leer</small>
      <small style="color:var(--fg3)">12,2 V · 50 %</small>
      <small style="color:var(--fg3)">12,8 V · voll</small>
    </div>
  </div>
  <div class="tile">
    <span class="label">Bewertung</span>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>Ladezustand (SOC)</dt>
        <dd style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
          ${letzter
            ? `<span class="dot" style="background:${bvFarbe(letzter.v, stufen)}"></span>${de(letzter.v, 1)} V · ${soc} % · ${bvSocLabel(soc)}`
            : `<span style="color:var(--fg3)">unbekannt</span>`}</dd></div>
      <div class="row" style="border-bottom:0"><dt>Gesundheitszustand (SOH-Trend)</dt>
        <dd style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
          ${soh.status
            ? `<span class="dot" style="background:${punktFarbe(soh.status)}"></span>${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V`
            : `<span style="color:var(--fg3)">-</span>`}</dd></div>
    </dl>
    <span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">${soh.text}</span>
  </div>`;
}

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 || !bvYDomain) return;
  // Feste Y-Achse (siehe vBatterieverlauf) statt Neuberechnung aus dem
  // gerade sichtbaren Zoom-Ausschnitt, damit die Achse beim Zoomen/Verschieben
  // nicht mehr "streckt".
  const [yMin, yMax] = bvYDomain;

  // 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 `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${BV_W - BV_MR}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
      <text x="${BV_ML - 6}" y="${(y + 3).toFixed(1)}" font-size="11" fill="var(--fg3)" text-anchor="end">${de(v, 1)}</text>`;
  }).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 `<text x="${x.toFixed(1)}" y="${BV_H - 6}" font-size="11" fill="var(--fg3)"
      text-anchor="${f === 0 ? "start" : f === 1 ? "end" : "middle"}">${kurzdat(new Date(t))}</text>`;
  }).join("");
  const stufen = bvFarbstufen();
  const kreise = rahmen.map((p) => {
    const idx = bvPunkte.indexOf(p);
    const gewaehlt = idx === bvAusgewaehlt;
    return `<circle cx="${bvSkalaX(p.t).toFixed(1)}" cy="${bvSkalaY(p.v, yMin, yMax).toFixed(1)}"
      r="${gewaehlt ? 4.5 : 2.4}" fill="${bvFarbe(p.v, stufen)}"
      ${gewaehlt ? 'stroke="var(--fg)" stroke-width="1"' : ""}/>`;
  }).join("");

  svg.innerHTML = `${gitter}<polyline points="${linie}" fill="none" stroke="var(--fg)" stroke-width="1.5"/>${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 `
  <div class="tile"><span class="label">Anstehende Termine</span>
    ${termine().map((t) => {
      const artIcon = ART_ICON[t.art];
      const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
      const kopf = `<div class="tg-name">${iconMarkup}<span>${t.art}</span></div>`;
      // Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne
      // Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein
      // frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und
      // "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag,
      // zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag
      // im Servicebuch" - iv war null und der frühe Return griff, bevor fm
      // überhaupt gelesen wurde.
      const fm = fahrzeugMeldungRoh(t.art);
      const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
      const hatMeldung = fm.km != null || fmDatum;
      const iv = intervalle(t);
      if (!iv && !hatMeldung) return `<div class="termingruppe">
          <div class="tg-kopf">${kopf}</div>
          <div class="tg-wert leer">kein Eintrag im Servicebuch</div>
        </div>`;
      const anzeige = iv ? (iv.hersteller || iv.eigen) : null;
      // Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
      // eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
      // Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
      // dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null
      // ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) -
      // dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten.
      const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
      const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
      // Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
      // die Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei
      // Herstellervorgabe unverändert, beim Ölwechsel mit eigenem (kürzerem)
      // Intervall über oelMeldungsPrognose() auf dieses umgerechnet, statt es
      // bis zum ersten Servicebucheintrag zu ignorieren (siehe deren
      // Kommentar). Hochgerechnet wird dabei mit der echten Fahrleistung aus
      // dem Fahrtenlog statt einer Servicebuch-Rate.
      const meldungProg = hatMeldung && !eigenOel && !eigenInsp
        ? (t.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(t.art))
        : null;
      // Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
      // Herstellerintervall - die Restkilometerzahl daneben waere nur eine
      // zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
      // eigenen (kuerzeren) Intervall ist sie eine echte Zusatzinformation.
      const fussnote = hatMeldung && eigenOel
        ? (CAR.oel.modus === "hersteller"
            ? `voraussichtlich am ${dedat(eigenOel.datum)}`
            : `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
        : eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
        : meldungProg
          ? (t.art === "Ölwechsel" && CAR.oel.modus !== "hersteller" && meldungProg.restKm != null
              ? `${de(meldungProg.restKm)} km, voraussichtlich am ${dedat(meldungProg.datum)}`
              : `voraussichtlich am ${dedat(meldungProg.datum)}`)
          : "";
      const wert = hatMeldung
        ? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
        : (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
      // Eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm
      // des Bordcomputers - das kennt das eigene, in "Einrichten" gewaehlte
      // Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
      // als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
      // (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
      const herstellervorgabe = hatMeldung || (iv && iv.hersteller);
      return `<div class="termingruppe">
          <div class="tg-kopf">
            ${kopf}
            ${t.vereinbart ? `<span class="tg-chip">Werkstatt am ${dedat(new Date(t.vereinbart))}</span>` : ""}
          </div>
          ${herstellervorgabe ? `<div class="tg-vorgabe">Herstellervorgabe</div>` : ""}
          <div class="tg-wert">${wert}</div>
          ${fussnote ? `<div class="tg-prognose">${fussnote}</div>` : ""}
        </div>`; }).join("")}
  </div>
  <div class="tile"><span class="label">Termin vereinbaren</span>
    ${(() => { 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 `
      <div class="feld"><label for="svArt">Wofür</label>
        <select id="svArt" style="width:132px" data-svsel>${ts.map((x, i) => `<option value="${i}" ${i === svAuswahl ? "selected" : ""}>${x.art}</option>`).join("")}</select></div>
      <div class="feld" style="border-bottom:0"><label for="svDat">Werkstatttermin am</label>
        <input id="svDat" type="date" style="width:132px" value="${vorschlag}" data-vereinbart="${esc(t.art)}"></div>
      <button class="aktion" data-ics="service">In den Kalender übernehmen</button>
      ${CAR.service.vereinbart[t.art] ? `<button class="aktion" data-loeschen="${esc(t.art)}">Termin verwerfen</button>` : ""}`; })()}
  </div>
  <div class="tile">
    <span class="label">Autohaus</span>
    <button class="zahnrad" data-go="werkstatt:x" aria-label="Angaben ändern">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <div style="font-size:15px;margin-top:12px">${esc(sv.autohaus.name)}</div>
    <span class="label" style="margin-top:8px">${esc(sv.autohaus.adresse)}</span>
    <a class="aktion" href="tel:${esc(sv.autohaus.tel.replace(/\s/g, ""))}"
       style="display:block;text-decoration:none;text-align:center;margin-top:16px">${esc(sv.autohaus.tel)}</a>
    ${sv.autohaus.mail ? `<a class="aktion" href="mailto:${esc(sv.autohaus.mail)}"
       style="display:block;text-decoration:none;text-align:center">E-Mail schreiben</a>` : ""}
    <a class="aktion" target="_blank" rel="noopener"
       href="https://maps.google.com/?q=${encodeURIComponent(sv.autohaus.adresse)}"
       style="display:block;text-decoration:none;text-align:center">In Karten öffnen</a>
  </div>
  <div class="tile"><span class="label">Servicebuch</span>
    ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, `<button class="leaf" style="padding-left:0" data-go="sbuch:${i}">
      <span class="k">${esc(e.art)}<small>${esc(e.werkstatt)}</small></span>
      <span class="v">${e.datum}<small>${de(e.km)} km${e.kosten != null ? " · " + eur(e.kosten) + " €" : ""}</small></span>
      <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`)).join("") : `<div class="leer" style="margin-top:12px">
      <strong>Noch keine Einträge</strong>
      <p>Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.</p>
    </div>`}
    <button class="aktion primaer" data-sbneu>Eintrag hinzufügen</button>
  </div>`;
}

function vIdent() {
  return `
  <div class="tile"><span class="label">Fahrzeug</span>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>Modell</dt><dd>${esc(CONFIG.fahrzeugtitel)}</dd></div>
      <div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
      <div class="row"><dt>Fahrgestellnummer</dt><dd>${esc(CAR.fin)}</dd></div>
      <div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div>
      <div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
      <div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div>
      <div class="row" style="border-bottom:0"><dt>Zulassung</dt><dd>Deutschland</dd></div>
    </dl></div>
  ${CAR.technik.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
    <dl class="rows" style="margin-top:8px">
      ${g.posten.map((pp, i) => `<div class="row" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt>${esc(pp[0])}</dt>
        <dd style="${pp[1] ? "" : "color:var(--fg3)"}">${pp[1] ? esc(pp[1]) : "noch offen"}</dd></div>`).join("")}
    </dl></div>`).join("")}
  ${CAR.ausstattung.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
    <dl class="rows" style="margin-top:8px">
      ${g.sonder.map((x, i) => `<div class="row" ${i === g.sonder.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
    </dl></div>`).join("")}
  <button class="aktion">Daten bearbeiten</button>`;
}

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;
  }
  const inspTermin = bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24);
  // Ganz ohne Servicebucheintrag (kein basis) übernimmt die Fahrzeugmeldung
  // selbst die Rolle der Prognosequelle - genau wie vService()'s eigenes
  // meldungProg das schon tut (siehe dessen Kommentar dort). Ohne diesen
  // Fallback hier zeigte "Übersicht" trotz korrekt gemeldeter Fälligkeit nur
  // "kein Eintrag im Servicebuch", während "Service" dieselbe Meldung
  // längst richtig anzeigte - zwei Ansichten, ein Datenstand, zwei
  // widersprüchliche Aussagen.
  [oelTermin, inspTermin].forEach((termin) => {
    if (termin.basis) return;
    const m = termin.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(termin.art);
    if (!m) return;
    termin.km = m.restKm != null && CAR.odoBekannt ? CAR.odo + m.restKm : null;
    termin.kmDatum = m.datum;
    termin.zuerst = "km";
    termin.ziel = m.datum;
  });
  return [
    oelTermin,
    inspTermin,
    bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
      CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
  ];
}

/* Liest Name, Adresse, Telefon und E-Mail aus einer vCard (.vcf) - deckt
   vCard 3.0/4.0 so weit ab, wie ein typischer Werkstatt-Kontakt aus dem
   Adressbuch (iOS/Android/Outlook) sie befüllt. Kein vollständiger Parser,
   nur die vier Felder, die das Autohaus-Formular kennt.

   Gefaltete Zeilen (Fortsetzung mit führendem Leerzeichen/Tab, RFC 6350
   §3.2) werden vor dem zeilenweisen Parsen entfaltet; ADR setzt sich aus
   PO-Box, Zusatz, Straße, Ort, Region, PLZ, Land zusammen (in dieser
   Reihenfolge, mit Semikolon getrennt) - hier zu einer lesbaren
   Ein-Zeilen-Adresse zusammengefasst, weil das Formularfeld nur eine
   Zeile kennt. Mehrfache TEL/EMAIL/ADR-Zeilen: die erste gewinnt. */
function vcardParsen(text) {
  const entfaltet = String(text).replace(/\r\n/g, "\n").replace(/\n[ \t]/g, "");
  const zeilen = entfaltet.split("\n").map((z) => z.trim()).filter(Boolean);
  const entwerten = (s) => s.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
  let name = null, org = null, tel = null, mail = null, adresse = null;
  for (const zeile of zeilen) {
    const doppelpunkt = zeile.indexOf(":");
    if (doppelpunkt < 0) continue;
    const eigenschaft = zeile.slice(0, doppelpunkt).split(";")[0].toUpperCase();
    const wert = zeile.slice(doppelpunkt + 1);
    if (eigenschaft === "FN" && !name) name = entwerten(wert);
    else if (eigenschaft === "ORG" && !org) org = entwerten(wert.split(";")[0]);
    else if (eigenschaft === "TEL" && !tel) tel = entwerten(wert);
    else if (eigenschaft === "EMAIL" && !mail) mail = entwerten(wert);
    else if (eigenschaft === "ADR" && !adresse) {
      const teile = wert.split(";").map(entwerten);
      const strasse = [teile[1], teile[2]].filter(Boolean).join(" ");
      const ort = [teile[5], teile[3]].filter(Boolean).join(" ");
      adresse = [strasse, ort, teile[6]].filter(Boolean).join(", ") || null;
    }
  }
  if (!name && !org && !tel && !mail && !adresse) {
    throw new Error("Keine lesbaren Kontaktfelder gefunden - ist das eine .vcf-Datei?");
  }
  return { name: name || org || null, adresse, tel, mail };
}

function vWerkstatt() {
  const a = CAR.service.autohaus;
  return `
  <div class="tile"><span class="label">Angaben zum Autohaus</span>
    <div class="feld"><label>Name</label><input value="${esc(a.name)}" data-ah="name"></div>
    <div class="feld"><label>Adresse</label><input value="${esc(a.adresse)}" data-ah="adresse"></div>
    <div class="feld"><label>Telefon</label><input type="tel" value="${esc(a.tel)}" data-ah="tel"></div>
    <div class="feld" style="border-bottom:0"><label>E-Mail</label>
      <input type="email" value="${esc(a.mail || "")}" data-ah="mail" placeholder="noch offen"></div>
    <label class="aktion" style="display:block;text-align:center;margin-top:16px;cursor:pointer">
      vCard importieren<input type="file" accept=".vcf,text/vcard" data-vcard-import hidden></label>
  </div>`;
}

function vSbuch() {
  const i = parseInt(route.id, 10), e = CAR.service.buch[i];
  if (!e) return `<div class="tile">Eintrag nicht gefunden</div>`;
  const istEntwurf = entwurfIndex === i;
  const artBekannt = SERVICE_ARTEN.includes(e.art);
  return `
  <div class="tile"><span class="label">${esc(e.art)}</span>
    <div class="fig" style="font-size:40px;margin-top:12px">${de(e.km)}<span class="unit">km</span></div>
    <span class="label" style="margin-top:12px">${e.datum} · ${esc(e.werkstatt)}</span>
  </div>
  <div class="tile"><span class="label">Eintrag</span>
    <div class="feld"><label>Datum</label><input value="${esc(e.datum)}" data-sb="${i}|datum"></div>
    <div class="feld"><label>Kilometerstand</label>
      <span class="mitEinheit"><input type="number" value="${e.km}" data-sb="${i}|km"><span>km</span></span></div>
    <div class="feld"><label>Art</label>
      <select data-sb="${i}|art">
        ${!artBekannt ? `<option value="${esc(e.art)}" selected>${esc(e.art)}</option>` : ""}
        ${SERVICE_ARTEN.map((a) => `<option ${a === e.art ? "selected" : ""}>${esc(a)}</option>`).join("")}
      </select></div>
    <div class="feld"><label>Werkstatt</label><input value="${esc(e.werkstatt)}" data-sb="${i}|werkstatt"></div>
    <div class="feld" style="border-bottom:0"><label>Kosten</label>
      <span class="mitEinheit"><input type="number" step="0.01" value="${e.kosten ?? ""}"
        data-sb="${i}|kosten" placeholder="noch offen"><span>€</span></span></div>
    <span class="label" style="margin-top:14px">Kosten fließen in die Jahresübersicht ein</span>
  </div>
  <div class="tile"><span class="label">Durchgeführte Arbeiten</span>
    <textarea class="notiz" data-sb="${i}|arbeiten" rows="7"
      placeholder="Was wurde gemacht, welche Teile wurden getauscht, was ist aufgefallen">${esc(e.arbeiten || "")}</textarea>
    <span class="label" style="margin-top:12px">Beim Verkauf ist diese Liste mehr wert als die Rechnung —
      sie zeigt, dass gepflegt wurde</span>
  </div>
  ${istEntwurf ? `<button class="aktion" data-sbspeichern>Speichern</button>`
    : `<button class="aktion loeschen" data-sbdel="${i}">Eintrag löschen</button>`}`;
}

function vVers() {
  const v = CAR.versicherung;
  return `
  <div class="tile"><span class="label">Versicherung - Schaden Notruf 24/7</span>
    <button class="zahnrad" data-go="notruf:x" aria-label="Rufnummern ändern">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <a class="aktion" href="tel:${esc(v.service.replace(/\s/g, ""))}"
       style="display:block;text-decoration:none;margin-top:14px;text-align:center">
       Inland · ${esc(v.service)}</a>
    <a class="aktion" href="tel:${esc(v.notfall.replace(/\s/g, ""))}"
       style="display:block;text-decoration:none;text-align:center">
       Ausland · ${esc(v.notfall)}</a>
  </div>
  <div class="tile"><span class="label">Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))}</span>
    <button class="zahnrad" data-go="beitrag:x" aria-label="Beitrag anpassen">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <div class="fig" style="font-size:46px;margin-top:12px">${eur(v.beitrag)}<span class="unit">€</span></div>
    <span class="label" style="margin-top:14px">${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${esc(v.steuersatz)})</span>
    <dl class="rows" style="margin-top:16px">
      ${v.teile.map((t, i) => `<div class="row" ${i === v.teile.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt>${esc(t.bez)}${t.sf ? `<br><span style="color:var(--fg3);font-size:11px">${esc(t.sf)} · Beitragssatz ${esc(t.satz)}</span></dt>` : "</dt>"}
        <dd>${eur(t.betrag)} €<small>${v.beitrag ? de(t.betrag / v.beitrag * 100, 0) : 0} %</small></dd></div>`).join("")}
    </dl>
    <div class="bar" style="margin-top:18px">
      ${v.teile.map((t, i) => `<i style="flex:${t.betrag || 0.01};background:${["var(--fg)", "var(--fg2)", "var(--fg3)"][i] || "var(--fg3)"}"></i>`).join("")}
    </div>
  </div>
  <div class="tile"><span class="label">Vertrag</span>
    <button class="zahnrad" data-go="vertrag:x" aria-label="Vertrag bearbeiten">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <dl class="rows" style="margin-top:8px">
      <div class="row"><dt>Gesellschaft</dt><dd>${esc(v.gesellschaft)}</dd></div>
      <div class="row"><dt>Umfang</dt><dd>${esc(v.vertrag)}</dd></div>
      <div class="row"><dt>Vertragsnummer</dt><dd>${esc(v.nummer)}</dd></div>
      ${v.selbst.map(([b, w]) => `<div class="row"><dt>Selbstbeteiligung ${esc(b)}</dt><dd>${esc(w)}</dd></div>`).join("")}
      <div class="row" style="border-bottom:0"><dt>Schadenfreiheitsklasse</dt>
        <dd>${v.teile.map((t) => t.sf).filter(Boolean).join(" / ")}<small>zuvor ${v.teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}</small></dd></div>
    </dl>
    <button class="aktion" data-go="vertragsdetails:x">Details</button>
  </div>
  ${vSteuer()}`;
}

function vNotrufBearbeiten() {
  const v = CAR.versicherung;
  return `
  <div class="tile"><span class="label">Rufnummern ändern</span>
    <div class="feld"><label for="vNotrufInland">Inland</label>
      <input id="vNotrufInland" style="width:132px" value="${esc(v.service)}" data-vnotruf="service"></div>
    <div class="feld" style="border-bottom:0"><label for="vNotrufAusland">Ausland</label>
      <input id="vNotrufAusland" style="width:132px" value="${esc(v.notfall)}" data-vnotruf="notfall"></div>
  </div>`;
}

function vBeitrag() {
  const v = CAR.versicherung;
  return `
  <div class="tile"><span class="label">Beitrag anpassen</span>
    <div class="feld"><label for="vGueltigAb">Gültig ab</label>
      <input id="vGueltigAb" type="date" style="width:132px" value="${v.gueltigAb}" data-vgueltigab></div>
    <div class="feld"><label for="vSfHaft">Schadenfreiheitsklasse Haftpflicht</label>
      <input id="vSfHaft" style="width:132px" value="${esc(v.teile[0]?.sf || "")}" data-vteil="0|sf"></div>
    <div class="feld"><label for="vSfKasko">Schadenfreiheitsklasse Vollkasko</label>
      <input id="vSfKasko" style="width:132px" value="${esc(v.teile[1]?.sf || "")}" data-vteil="1|sf"></div>
    <div class="feld"><label for="vKostenHaft">Kosten Haftpflicht</label>
      <span class="mitEinheit"><input id="vKostenHaft" type="number" step="0.01" style="width:132px" value="${v.teile[0]?.betrag ?? 0}" data-vteil="0|betrag"><span>€</span></span></div>
    <div class="feld"><label for="vKostenKasko">Kosten Vollkasko</label>
      <span class="mitEinheit"><input id="vKostenKasko" type="number" step="0.01" style="width:132px" value="${v.teile[1]?.betrag ?? 0}" data-vteil="1|betrag"><span>€</span></span></div>
    <div class="feld" style="border-bottom:0"><label for="vKostenSchutz">Kosten Schutzbrief</label>
      <span class="mitEinheit"><input id="vKostenSchutz" type="number" step="0.01" style="width:132px" value="${v.teile[2]?.betrag ?? 0}" data-vteil="2|betrag"><span>€</span></span></div>
    <span class="label" style="margin-top:14px">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</span>
  </div>`;
}

function vVertragsdetails() {
  const v = CAR.versicherung;
  return `
  <button class="tile tilebtn" data-go="schutz:x">
    <span class="label">Schutzbrief Mobilität</span>
    <svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
    <div style="font-size:14px;margin-top:12px">Panne, Unfall, Diebstahl, Fahrerausfall</div>
    <span class="label" style="margin-top:10px">Nummern und Leistungen für den Ernstfall</span>
  </button>
  ${v.leistungen.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
    <dl class="rows" style="margin-top:8px">
      ${g.posten.map((x, i) => `<div class="row" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
    </dl></div>`).join("")}`;
}

function vVertragBearbeiten() {
  const v = CAR.versicherung;
  return `
  <div class="tile"><span class="label">Vertrag</span>
    <div class="feld"><label for="vGesellschaft">Gesellschaft</label>
      <input id="vGesellschaft" style="width:180px" value="${esc(v.gesellschaft)}" data-vvertrag="gesellschaft"></div>
    <div class="feld"><label for="vUmfang">Umfang</label>
      <input id="vUmfang" style="width:180px" value="${esc(v.vertrag)}" data-vvertrag="vertrag"></div>
    <div class="feld" style="border-bottom:0"><label for="vNummer">Vertragsnummer</label>
      <input id="vNummer" style="width:180px" value="${esc(v.nummer)}" data-vvertrag="nummer"></div>
  </div>
  <div class="tile" style="margin-top:26px"><span class="label">Selbstbeteiligung</span>
    ${v.selbst.map(([b, w], i) => `
    <div class="feld" ${i === v.selbst.length - 1 ? 'style="border-bottom:0"' : ""}>
      <label for="vSelbstB${i}">Position ${i + 1}</label>
      <span style="display:inline-flex;align-items:center;gap:8px">
        <input id="vSelbstB${i}" style="width:100px" value="${esc(b)}" placeholder="Bezeichnung" data-vselbst="${i}|0">
        <input id="vSelbstW${i}" style="width:80px" value="${esc(w)}" placeholder="Betrag" data-vselbst="${i}|1">
        <button class="zeile-loeschen" data-vselbstdel="${i}" aria-label="Position ${i + 1} löschen">×</button>
      </span>
    </div>`).join("")}
    ${!v.selbst.length ? `<span class="label" style="margin-top:8px">Keine Selbstbeteiligung hinterlegt</span>` : ""}
    <button class="aktion" data-vselbstadd>+ Position hinzufügen</button>
  </div>
  <div class="tile" style="margin-top:26px"><span class="label">Schadenfreiheitsklasse · zuvor</span>
    <div class="feld"><label for="vSfHaftAlt">Haftpflicht</label>
      <input id="vSfHaftAlt" style="width:132px" value="${esc(v.teile[0]?.sfAlt || "")}" data-vteil="0|sfAlt"></div>
    <div class="feld" style="border-bottom:0"><label for="vSfKaskoAlt">Vollkasko</label>
      <input id="vSfKaskoAlt" style="width:132px" value="${esc(v.teile[1]?.sfAlt || "")}" data-vteil="1|sfAlt"></div>
    <span class="label" style="margin-top:14px">Die aktuelle Schadenfreiheitsklasse änderst du unter
      "Beitrag anpassen" — hier steht nur der vorherige Wert für den Vergleich.</span>
  </div>`;
}

function vSchutz() {
  const v = CAR.versicherung, sb = v.schutzbrief;
  return `
  <div class="tile"><span class="label">Die Entscheidung am Schadenort</span>
    <div style="font-size:13px;color:var(--fg2);line-height:1.6;margin-top:10px">
      Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die
      anderen beiden.</div>
    <dl class="rows" style="margin-top:14px">
      ${sb.entweder.map((x, i) => `<div class="row" ${i === sb.entweder.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(x.bez)}</dt>
        <dd>${esc(x.grenze)}${x.zusatz ? `<small>${esc(x.zusatz)}</small>` : ""}</dd></div>`).join("")}
    </dl>
    <span class="label" style="margin-top:16px">Mobilität wird binnen 60 Minuten organisiert</span>
  </div>
  <div class="tile"><span class="label">Wann der Schutzbrief greift</span>
    <dl class="rows" style="margin-top:8px">
      ${sb.ausloeser.map((a, i) => `<div class="row" ${i === sb.ausloeser.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(a)}</dt><dd></dd></div>`).join("")}
    </dl>
    <span class="label" style="margin-top:16px">Gilt in ${esc(sb.gilt)}</span>
    <span class="label" style="margin-top:8px">Versichert: ${esc(sb.versichert)}</span>
  </div>
  ${sb.gruppen.map((g) => `<div class="tile"><span class="label">${esc(g.titel)}</span>
    <dl class="rows" style="margin-top:8px">
      ${g.posten.map((pp, i) => `<div class="row" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(pp[0])}${pp[2] ? `<br><span style="color:var(--fg3);font-size:11px">${esc(pp[2])}</span>` : ""}</dt>
        <dd>${esc(pp[1])}</dd></div>`).join("")}
    </dl></div>`).join("")}
  <div class="tile"><span class="label">Nicht versichert</span>
    <dl class="rows" style="margin-top:8px">
      ${sb.nicht.map((n, i) => `<div class="row" ${i === sb.nicht.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(n[0])}<br>
          <span style="color:var(--fg3);font-size:11px">${esc(n[1])}</span></dt><dd></dd></div>`).join("")}
    </dl></div>
  <div class="tile"><span class="label">Deine Pflichten</span>
    <dl class="rows" style="margin-top:8px">
      ${sb.pflichten.map((x, i) => `<div class="row" ${i === sb.pflichten.length - 1 ? 'style="border-bottom:0"' : ""}>
        <dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
    </dl>
    <div style="margin-top:16px;font-size:12.5px;color:var(--fg2);line-height:1.6">${esc(sb.rang)}</div>
    <span class="label" style="margin-top:14px">${esc(sb.stand)}</span>
  </div>`;
}

function vSteuer() {
  const t = CAR.steuer;
  return `
  <div class="tile" style="margin-top:26px"><span class="label">Kfz-Steuer</span>
    <button class="zahnrad" data-go="steuer:x" aria-label="Kfz-Steuer bearbeiten">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <dl class="rows" style="margin-top:10px">
      <div class="row"><dt>Betrag</dt><dd>${eur(t.betrag)} €<small>${t.zeitraum === "jährlich" ? "im Jahr" : esc(t.zeitraum)}</small></dd></div>
      <div class="row" style="border-bottom:0"><dt>Nächste Fälligkeit</dt>
        <dd>${t.faellig ? dedat(new Date(t.faellig)) : "unbekannt"}</dd></div>
    </dl>
  </div>`;
}

function vSteuerBearbeiten() {
  const t = CAR.steuer;
  return `
  <div class="tile"><span class="label">Kfz-Steuer</span>
    <div class="feld"><label for="stBetrag">Betrag</label>
      <span class="mitEinheit"><input id="stBetrag" type="number" step="1" style="width:132px" value="${t.betrag}"
        data-steuer><span>€</span></span></div>
    <div class="feld"><label for="stZeit">Zeitraum</label>
      <select id="stZeit" style="width:132px" data-steuerzeit>
        <option ${t.zeitraum === "jährlich" ? "selected" : ""}>jährlich</option>
        <option ${t.zeitraum === "halbjährlich" ? "selected" : ""}>halbjährlich</option>
      </select></div>
    <div class="feld"><label for="stFaellig">Nächste Fälligkeit</label>
      <input id="stFaellig" type="date" style="width:132px" value="${t.faellig || ""}" data-steuerfaellig></div>
    <div class="feld" style="border-bottom:0"><label for="stLast">Lastschrift erteilt</label>
      <label class="switch"><input id="stLast" type="checkbox" data-steuerlast ${t.lastschrift ? "checked" : ""}><span class="track"></span></label></div>
  </div>`;
}

/* 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 `
  <div class="tile"><span class="label">Fahrzeug einrichten</span>
    ${einrichtenOffen ? `
      <div class="feld" style="margin-top:16px"><label for="einFin">Fahrgestellnummer (FIN)</label>
        <input id="einFin" value="${esc(CAR.fin || "")}" data-einrichten="fin"></div>
      <div class="feld"><label for="einModell">Modell</label>
        <select id="einModell" data-einrichten="modell">
          ${(FAHRZEUG_MODELLE.includes(CONFIG.fahrzeugtitel) ? FAHRZEUG_MODELLE : [CONFIG.fahrzeugtitel, ...FAHRZEUG_MODELLE])
            .map((m) => `<option ${m === CONFIG.fahrzeugtitel ? "selected" : ""}>${esc(m)}</option>`).join("")}
        </select></div>
      <div class="feld"><label for="einKennzeichen">Kennzeichen</label>
        <input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div>
      <div class="feld"><label for="einErstzulassung">Erstzulassung</label>
        <input id="einErstzulassung" value="${esc(CAR.erstzulassung || "")}" data-einrichten="erstzulassung" placeholder="MM/JJJJ"></div>
      <div class="feld" style="border-bottom:0"><label for="einAusfuehrung">Ausführung</label>
        <input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div>
      <span class="label" style="margin-top:20px">Ölwechsel-Intervall</span>
      <div class="seg" style="margin-top:8px">
        <button data-oel="hersteller" class="${CAR.oel.modus === "hersteller" ? "on" : ""}">Herstellervorgabe</button>
        <button data-oel="eigen" class="${CAR.oel.modus === "eigen" ? "on" : ""}">Individuell</button>
      </div>
      ${CAR.oel.modus === "hersteller"
        ? `<dl class="rows" style="margin-top:16px">
            <div class="row"><dt>Laufleistung</dt><dd>${de(CAR.oel.herstellerKm)} km</dd></div>
            <div class="row" style="border-bottom:0"><dt>Zeit</dt><dd>${CAR.oel.herstellerMonate} Monate</dd></div>
          </dl>`
        : `<div class="feld" style="margin-top:6px"><label for="oelKm">Nach Laufleistung</label>
            <select id="oelKm" data-oelkm>${[5000, 10000, 15000, 20000].map((k) => `<option value="${k}" ${k === CAR.oel.km ? "selected" : ""}>${de(k)} km</option>`).join("")}</select></div>
           <div class="feld" style="border-bottom:0"><label for="oelM">Nach Zeit</label>
            <select id="oelM" data-oelm>${[12, 24].map((m) => `<option value="${m}" ${m === CAR.oel.monate ? "selected" : ""}>${m === 12 ? "1 Jahr" : "2 Jahre"}</option>`).join("")}</select></div>`}
      <button class="aktion" style="margin-top:20px" data-setup-oeffnen>Setup — Sensoren zuordnen</button>
      <button class="aktion" data-import-oeffnen>Daten importieren aus Home Assistant</button>
      <span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
        text-transform:none;font-size:12.5px;line-height:1.6">
        Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
        nachträglich an — aus dem Verlauf, den Home Assistant zu den oben zugeordneten
        Sensoren bereits aufgezeichnet hat.</span>
    ` : ""}
    <button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
  </div>
  <div class="tile"><span class="label">Darstellung</span>
    <div class="seg">
      <button data-mode="nacht" class="${HOST.dataset.theme === "nacht" ? "on" : ""}">Nacht</button>
      <button data-mode="tag" class="${HOST.dataset.theme === "tag" ? "on" : ""}">Tag</button>
    </div>
    <div class="feld" style="margin-top:16px;border-bottom:0">
      <label for="schrift">Beschriftung in der Menüleiste</label>
      <label class="switch"><input id="schrift" type="checkbox" data-schrift ${beschriftung ? "checked" : ""}><span class="track"></span></label></div>
    <span class="label" style="margin-top:14px">Aus zeigt nur die Symbole und gibt etwas Höhe frei</span>
    <div class="feld" style="margin-top:16px;border-bottom:0">
      <label for="boden">Fahrbahn unter dem Fahrzeug</label>
      <label class="switch"><input id="boden" type="checkbox" data-boden ${bodenAn ? "checked" : ""}><span class="track"></span></label></div>
    <span class="label" style="margin-top:14px">Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten</span>
  </div>
  <div class="tile"><span class="label">Bild der Übersicht</span>
    <div class="feld" style="border-bottom:0"><label for="startbild">Ansicht</label>
      <select id="startbild" data-startbild>${BILDER.map((b) => `<option ${b.name === CONFIG.startbild ? "selected" : ""}>${b.name}</option>`).join("")}</select></div>
    <span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
      text-transform:none;font-size:12.5px;line-height:1.6">
      Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen.</span>
    ${(() => {
      const info = bildInfo(startIndex());
      return `<div style="margin-top:12px">
        <div data-bildklick="${esc(info.datei)}">
          ${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", null, true)}
        </div>
        <input type="file" accept="image/*" hidden data-bildupload="${esc(info.datei)}">
        ${bildMenuOffen === info.datei ? `
        <div class="bildmenu-catcher" data-bildmenu-schliessen></div>
        <div class="bildmenu ansicht">
          <button type="button" data-bildersetzen="${esc(info.datei)}">Foto ersetzen</button>
          <button type="button" class="loeschen" data-bildloeschen="${esc(info.datei)}">Löschen</button>
        </div>` : ""}
      </div>`;
    })()}
  </div>
  <div class="tile"><span class="label">Kraftstoff-Rabatt</span>
    <div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
        <img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
      <label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
    ${CAR.smartdeal.aktiv ? `
    <div class="feld" style="border-bottom:0"><label>Gültig bis</label>
      <span>${dedat(new Date(CAR.smartdeal.laeuftAb))}</span></div>` :
      `<span class="label" style="margin-top:14px">Ersparnis und Bildmarke werden ausgeblendet</span>`}
    ${smartdealPopupOffen ? `
    <div class="bildmenu-catcher" data-sdabbrechen></div>
    <div class="sdpopup">
      <div class="feld" style="border-bottom:0"><label for="sdPopupBis">Gültig bis</label>
        <input id="sdPopupBis" type="date" style="width:132px" value="${CAR.smartdeal.laeuftAb || ""}"></div>
      <div class="seg" style="margin-top:14px">
        <button type="button" data-sdabbrechen>Abbrechen</button>
        <button type="button" class="on" data-sdbestaetigen>Bestätigen</button>
      </div>
    </div>` : ""}
  </div>
  <div class="tile"><span class="label">Fahrten zusammenfassen</span>
    <div class="feld" style="border-bottom:0"><label for="pause">Pause bis</label>
      <span class="mitEinheit"><input id="pause" type="number" step="5" min="0"
        value="${CONFIG.pauseMin}" data-pause><span>Minuten</span></span></div>
    <span class="label" style="margin-top:14px">Steigst du kürzer als diese Zeit aus — etwa zum Tanken —,
      bleibt es eine zusammenhängende Fahrt statt zweier</span>
  </div>
  <div class="tile"><span class="label">Fahrzeugprofil</span>
    <span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
      text-transform:none;font-size:12.5px;line-height:1.6">
      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.</span>
    <button class="aktion" data-profil="export">Profil sichern</button>
    <label class="aktion" style="display:block;text-align:center;cursor:pointer">
      Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
  </div>
  <div class="tile"><span class="label">Backup</span>
    <span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
      text-transform:none;font-size:12.5px;line-height:1.6">
      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.</span>
    ${CONFIG.letztesBackup ? `<span class="label" style="margin-top:14px">Letztes Backup:
      ${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr</span>` : ""}
    <button class="aktion" data-backup="jetzt">Backup jetzt erstellen</button>
    <div class="feld" style="margin-top:16px;border-bottom:0"><label for="backupIntervall">Autom. Backup</label>
      <select id="backupIntervall" data-backupintervall>
        ${[["aus", "Aus"], ["taeglich", "Täglich"], ["woechentlich", "Wöchentlich"], ["monatlich", "Monatlich"]]
          .map(([v, l]) => `<option value="${v}" ${v === CONFIG.backupIntervall ? "selected" : ""}>${l}</option>`).join("")}
      </select></div>
    <button class="aktion" data-backup="export" style="margin-top:16px">Backup exportieren</button>
    <label class="aktion" style="display:block;text-align:center;cursor:pointer">
      Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
  </div>
  <div class="tile"><span class="label">Daten ausgeben</span>
    <span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">
      Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
      Tabellenprogrammen aufgehen.</span>
    <button class="aktion" data-csv="fahrten">Fahrten als CSV</button>
    <button class="aktion" data-csv="tanken">Tankvorgänge als CSV</button>
    <button class="aktion" data-csv="service">Servicebuch als CSV</button>
  </div>
  <div class="tile"><span class="label">Version</span>
    <dl class="rows">
      <div class="row"><dt>Dashboard</dt><dd>${esc(CONFIG.version)}</dd></div>
      <div class="row"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
      <div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
      <div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
    </dl>
  </div>
  <div class="tile"><span class="label">Integration-Update</span>
    ${(() => {
      const u = INTEGRATION_UPDATE;
      const laeuft = INTEGRATION_UPDATE_LAEUFT;
      // Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
      // Control blockiert dessen Ausführung zuverlässig) - install.ps1
      // bleibt nur noch für die Erstinstallation nötig, bevor die
      // Integration überhaupt läuft und sich selbst aktualisieren kann.
      //
      // HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
      // lang, ohne verlässliche Fortschrittsangabe - ein laufender
      // Indikator statt eines stumm daliegenden Knopfes verhindert, dass
      // das wie ein Hänger aussieht (derselbe Grund wie beim
      // Erstladen-Spinner, .lade-spinner). Nach Erfolg führt der Knopf
      // direkt zum nächsten nötigen Schritt (Neustart), statt den Nutzer
      // selbst suchen zu lassen.
      const spinner = (text) => `<div style="display:flex;align-items:center;gap:10px;margin-top:8px">
          <span class="lade-spinner" aria-hidden="true"></span>
          <span class="label" style="margin:0">${esc(text)}</span>
        </div>`;
      if (laeuft === "installieren") return spinner("Installiere …");
      if (laeuft === "neustart") return spinner("Home Assistant startet neu …");
      if (u && u.installiert) {
        return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
            Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
            geladen.</span>
          <button class="aktion" style="margin-top:14px" data-update="neustart">Installation abschließen - Jetzt neu starten</button>`;
      }
      if (laeuft === "pruefen") return spinner("Prüfe …");
      if (u && u.verfuegbar) {
        return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
          <span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
            Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
            muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
            Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
          <button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
      }
      return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
          ${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}</span>
        <button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
    })()}
    ${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler && !INTEGRATION_UPDATE_LAEUFT
      ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
      : ""}
  </div>`;
}

let nmOffen = false;
let einrichtenOffen = false;
let bildMenuOffen = null;
let smartdealPopupOffen = false;
// "sommer"|"winter", welcher Reifensatz gerade sein km-Korrektur-Feld offen
// hat (Editier-Symbol neben der km-Anzeige) - null wenn keins offen ist.
let kmOffen = null;

/* --------------------------------------------------------- Setup-Popup
   Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
   Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern"
   an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */
let setupOffen = false;
let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder
let setupZuordnungStart = null; // Schnappschuss beim Öffnen - erlaubt "Abbrechen" ohne Nebeneffekt
let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken
let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null
let setupSuchtext = "";
let setupSpeichertLaeuft = false;
let ALLE_ENTITAETEN = [];

function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; }

function alleEntitaeten() {
  if (!HASS || !HASS.states) return [];
  return Object.keys(HASS.states).map((id) => {
    const attrs = (HASS.states[id] && HASS.states[id].attributes) || {};
    return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" };
  });
}

function entitaetName(id) {
  if (!id) return "";
  const treffer = ALLE_ENTITAETEN.find((e) => e.id === id);
  return treffer ? treffer.name : id;
}

/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten,
   Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der
   Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart,
   siehe entitaetKandidaten()). */
function entitaetScore(feld, e, suchtext) {
  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);
}

/* Hier stand bis 2026-08-23 setupGeaenderteTriggerFelder(): drei Felder
   (Zündung, Kilometerstand, Tankfüllstand) waren in der pyscript-Fassung
   beim Modul-Laden fest in die Trigger-Registrierung eingebacken und wirkten
   erst nach einem Neustart von Home Assistant. Das Setup-Fenster musste
   deshalb erkennen, ob eines davon geändert wurde, davor warnen und einen
   Neustart-Knopf anbieten.

   Die Integration bindet ihre Zustandsbeobachter bei jedem Speichern neu
   (trigger_neu_binden in koordinator.py). Die Warnung, der Knopf und der
   Dienst dahinter sind damit ersatzlos entfallen - Speichern wirkt jetzt bei
   allen Feldern sofort. */

function setupSpeichernAusfuehren() {
  // Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf
  // irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben()
  // ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles.
  if (!setupZuordnung || !Object.keys(setupZuordnung).length) {
    hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen.");
    return;
  }
  setupSpeichertLaeuft = true; render();
  (async () => {
    try {
      await HASS.callService(DOMAIN, "entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) });
    } catch (err) {
      setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return;
    }
    setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render();
  })();
}

/* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert
   liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in
   der App zu bemerken. */
function entitaetStatusWarnung(wert) {
  if (!wert) return "";
  const zustand = HASS.states[wert] ? HASS.states[wert].state : null;
  if (zustand === null) return `<div class="setup-feld-warnung">Entität nicht gefunden.</div>`;
  if (zustand === "unavailable" || zustand === "unknown") return `<div class="setup-feld-warnung">Aktuell „${esc(zustand)}" - kein Wert vom Sensor.</div>`;
  return "";
}
function vReifen() {
  const r = CAR.reifen;
  const feld = (k, schluessel, bez, typ) => {
    const t = r[k];
    return `<div class="feld"><label>${bez}</label>
      <input type="${typ || "text"}" value="${esc(t[schluessel] ?? "")}"
             data-rf="${k}|${schluessel}" placeholder="noch offen"></div>`;
  };
  const satz = (k) => {
    const t = r[k], aktiv = r.aktiv.toLowerCase() === k;
    const radDatei = `rad-${k}.webp`;
    const radLabel = k === "sommer" ? "Sommerrad" : "Winterrad";
    const satzLabel = k === "sommer" ? "Sommerräder" : "Winterräder";
    return `<div class="tile">
      <div class="radkopf-row">
        <div class="radkopf">
          <span class="tile-headline">${satzLabel}</span>
          <div class="fig" style="font-size:32px;margin-top:14px;display:flex;align-items:center;gap:8px">
            <button type="button" class="km-edit" data-kmauf="${k}" aria-label="${satzLabel} Kilometerstand ändern">${ciSVG(CI.editS, 16)}</button>
            ${t.km != null ? de(t.km) : "–"}<span class="unit">km</span>
          </div>
        </div>
        <div data-bildklick="${radDatei}">
          ${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")}
        </div>
      </div>
      ${kmOffen === k ? `
      <div style="margin-top:14px;border-top:1px solid var(--line);padding-top:16px">
        <div class="feld" style="border-bottom:0"><label for="km-${k}">Neuer Wert</label>
          <span class="mitEinheit"><input id="km-${k}" type="number" step="1" min="0"
            value="${t.km ?? ""}"><span>km</span></span></div>
        <span class="label" style="margin-top:6px;color:var(--fg2);letter-spacing:0;
          text-transform:none;font-size:12.5px;line-height:1.6">
          Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem
          Satz zählen ab hier weiter dazu.</span>
        <button class="aktion primaer" data-kmspeichern="${k}">Speichern</button>
        <button class="aktion" data-kmauf="${k}">Abbrechen</button>
      </div>` : ""}
      <input type="file" accept="image/*" hidden data-bildupload="${radDatei}">
      ${bildMenuOffen === radDatei ? `
      <div class="bildmenu-catcher" data-bildmenu-schliessen></div>
      <div class="bildmenu rad">
        <button type="button" data-bildersetzen="${radDatei}">Foto ersetzen</button>
        <button type="button" class="loeschen" data-bildloeschen="${radDatei}">Löschen</button>
      </div>` : ""}
      <button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
      <div style="margin-top:22px">
        ${feld(k, "marke", "Hersteller")}
        ${feld(k, "modell", "Modell")}
        ${feld(k, "mass", "Größe")}
        ${feld(k, "dot", "DOT")}
        <div class="feld"><label>Solldruck vorn / hinten</label>
          <span class="mitEinheit">
            <input class="druckfeld" value="${esc(t.druck_vorne ?? "")}" data-rf="${k}|druck_vorne" placeholder="–">
            <span>/</span>
            <input class="druckfeld" value="${esc(t.druck_hinten ?? "")}" data-rf="${k}|druck_hinten" placeholder="–">
            <span>bar</span>
          </span>
        </div>
        ${feld(k, "kommentar", "Kommentar")}
      </div>
    </div>`;
  };
  return `
  <div class="tile"><span class="label">Anzugsmoment der Radschrauben</span>
    <button class="zahnrad" data-nmauf aria-label="Anzugsmoment ändern">
      <svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
    </button>
    <div class="fig" style="font-size:34px;margin-top:12px">${de(r.anzugsmoment)}<span class="unit">Nm</span></div>
    ${nmOffen ? `
      <div style="margin-top:18px;border-top:1px solid var(--line);padding-top:16px">
        <div class="feld" style="border-bottom:0"><label for="nm">Neuer Wert</label>
          <span class="mitEinheit"><input id="nm" type="number" step="5" min="0"
            value="${r.anzugsmoment}"><span>Nm</span></span></div>
        <button class="aktion primaer" data-nmspeichern>Speichern</button>
        <button class="aktion" data-nmauf>Abbrechen</button>
      </div>` : ""}
  </div>
  <div class="tile"><span class="label">Wechseltermin</span>
    <div class="feld"><label for="rfDat">Geplant am</label>
      <input id="rfDat" type="date" value="${r.wechsel.datum}"></div>
    <div class="feld" style="border-bottom:0"><label for="rfOk">Termin vereinbart</label>
      <label class="switch"><input id="rfOk" type="checkbox" ${r.wechsel.vereinbart ? "checked" : ""}><span class="track"></span></label></div>
    <button class="aktion" data-ics="reifen">In den Kalender übernehmen</button>
  </div>
  ${satz("sommer")}
  ${satz("winter")}`;
}

/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
   Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander-
   laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die
   Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch,
   Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet
   das Backend ab. */
/* Gespeichert bleibt der kleingeschriebene Schlüssel - er wird an mehreren
   Stellen verglichen (Pille auf der Einzelfahrt, Statistik "Arbeitsweg", CSV)
   und steht so auch in allen bereits erfassten Fahrten. Angezeigt wird er
   groß, deshalb artText() statt eines geänderten Werts. */
const FAHRT_ARTEN = ["privat", "arbeitsweg"];
const artText = (a) => (a ? a.charAt(0).toUpperCase() + a.slice(1) : "");

/* "1 Std. 15 Min." statt "75 min" - bei mehrstündigen Fahrten liest sich die
   reine Minutenzahl schlecht. */
function dauerText(sekunden) {
  if (sekunden == null || !isFinite(sekunden)) return "–";
  const min = Math.max(0, Math.round(sekunden / 60));
  const std = Math.floor(min / 60);
  return std ? `${std} Std. ${min % 60} Min.` : `${min} Min.`;
}

/* Start und Ankunft eines Formulars als echte Zeitpunkte. Liegt die Ankunft
   vor der Abfahrt, wurde über Mitternacht gefahren - dann zählt der Folgetag.
   null, solange Datum oder eine der beiden Uhrzeiten fehlt. */
function fahrtZeitraum(praefix) {
  const datum = $(`#${praefix}Datum`).value, ab = $(`#${praefix}Start`).value, an = $(`#${praefix}Ende`).value;
  if (!datum || !ab || !an) return null;
  const start = new Date(`${datum}T${ab}:00`);
  let ende = new Date(`${datum}T${an}:00`);
  if (isNaN(start) || isNaN(ende)) return null;
  if (ende <= start) ende = new Date(ende.getTime() + dTag);
  return { start, ende };
}
function fahrtDauerText(praefix) {
  const z = fahrtZeitraum(praefix);
  return z ? dauerText((z.ende - z.start) / 1000) : "–";
}

/* Datum für ein <input type="date"> in Ortszeit. Bewusst nicht über
   toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
   den Vortag. */
function datumsfeld(d) {
  const zwei = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
function zeitfeld(d) {
  const zwei = (n) => String(n).padStart(2, "0");
  return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
}

function fahrtFelder(praefix, t) {
  const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
  const ende = t && t.ts_end ? new Date(t.ts_end) : null;
  const art = t && t.art ? t.art : FAHRT_ARTEN[0];
  const wert = (v) => (v == null ? "" : v);
  const startzeit = t ? zeitfeld(start) : "08:00", ankunftzeit = ende ? zeitfeld(ende) : "08:30";
  // Die Dauer ist bewusst kein Eingabefeld: sie ergibt sich aus Start und
  // Ankunft und würde sich sonst mit ihnen widersprechen können. Aus
  // t.duration_s statt neu berechnet, damit eine über Mitternacht laufende
  // bestehende Fahrt (Ankunft "kleiner" als Start) korrekt bleibt; bei einer
  // neuen Fahrt (t === null) ergibt sich der Startwert aus denselben
  // Vorgabezeiten wie die Felder selbst. Der data-fahrtzeitfeld-Handler
  // rechnet bei jeder Änderung neu.
  let dauerSekunden = t && t.duration_s != null ? t.duration_s : null;
  if (dauerSekunden == null) {
    const [sh, sm] = startzeit.split(":").map(Number), [eh, em] = ankunftzeit.split(":").map(Number);
    dauerSekunden = ((eh * 60 + em) - (sh * 60 + sm) + (eh * 60 + em <= sh * 60 + sm ? 1440 : 0)) * 60;
  }
  const dauer = dauerText(dauerSekunden);
  return `
      <div class="feld"><label for="${praefix}Datum">Datum</label><input type="date" id="${praefix}Datum" value="${datumsfeld(start)}" data-fahrtzeitfeld="${praefix}"></div>
      <div class="feld"><label for="${praefix}Start">Startzeit</label><input type="time" id="${praefix}Start" value="${startzeit}" data-fahrtzeitfeld="${praefix}"></div>
      <div class="feld"><label for="${praefix}Ende">Ankunftszeit</label><input type="time" id="${praefix}Ende" value="${ankunftzeit}" data-fahrtzeitfeld="${praefix}"></div>
      <div class="feld"><label>Dauer</label><span id="${praefix}DauerAnzeige" style="color:var(--fg2)">${dauer}</span></div>
      <div class="feld"><label for="${praefix}Art">Art</label>
        <select id="${praefix}Art">${FAHRT_ARTEN.map((a) => `<option value="${a}" ${a === art ? "selected" : ""}>${artText(a)}</option>`).join("")}</select></div>
      <div class="feld"><label for="${praefix}Startort">Startort</label><input id="${praefix}Startort" value="${esc(wert(t && t.start_address))}" placeholder="unbekannt"></div>
      <div class="feld"><label for="${praefix}Zielort">Zielort</label><input id="${praefix}Zielort" value="${esc(wert(t && t.end_address))}" placeholder="unbekannt"></div>
      <div class="feld"><label for="${praefix}OdoStart">Startkilometer</label><input type="number" id="${praefix}OdoStart" value="${wert(t && t.odo_start)}" placeholder="wird ergänzt"></div>
      <div class="feld"><label for="${praefix}OdoEnde">Endkilometer</label><input type="number" id="${praefix}OdoEnde" value="${wert(t && t.odo_end)}" placeholder="wird ergänzt"></div>
      <div class="feld" style="border-bottom:0"><label for="${praefix}Distanz">Gefahrene Distanz</label>
        <span class="mitEinheit"><input type="number" step="0.1" id="${praefix}Distanz" value="${wert(t && t.distance_km)}" placeholder="aus km-Stand"><span>km</span></span></div>`;
}

function fahrtFormularWerte(praefix) {
  const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
  const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
  const z = fahrtZeitraum(praefix);
  return {
    ts_start: z ? z.start.toISOString() : null,
    ts_end: z ? z.ende.toISOString() : null,
    art: $(`#${praefix}Art`).value,
    start_ort: text("Startort"), ziel_ort: text("Zielort"),
    odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"),
  };
}

function vTripsFormular() {
  // eigene Funktion, damit sowohl der volle als auch der Leer-Zustand
  // (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe
  // Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf
  // wechselte zu "Abbrechen", aber es erschienen keine Felder.
  return `${fahrtOffen ? `<div class="tile"><span class="label">Neue Fahrt</span>
      ${fahrtFelder("neuFahrt", null)}
      <button class="aktion primaer" data-fahrtspeichern>Speichern</button></div>` : ""}
    <button class="aktion ${fahrtOffen ? "" : "primaer"}" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
}
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 `<div class="tile"><div class="leer">
      <strong>Noch keine Fahrten</strong>
      <p>Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.</p>
    </div></div>
    ${vTripsFormular()}`;
  let h = `<div class="tile">`;
  jahre.forEach((j, ji) => {
    const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j);
    h += `<button class="acc-h" aria-expanded="${ji === 0}" data-acc="y${j}"><span class="mark"></span>
        <span class="k">${j}</span><span class="v">${de(sum(tj, (t) => t.distance_km || 0))} km<br>${tj.length} Fahrten</span></button>
      <div class="acc-b ${ji === 0 ? "on" : ""}" id="y${j}">`;
    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 += `<div class="lvl2"><button class="acc-h" aria-expanded="${mi === 0}" data-acc="m${j}${m}">
          <span class="mark"></span><span class="k">${m}</span>
          <span class="v">${de(sum(tm, (t) => t.distance_km || 0))} km</span></button>
        <div class="acc-b ${mi === 0 ? "on" : ""}" id="m${j}${m}">`;
      tm.forEach((t) => { h += swipeZeile(`trip:${t.trip_id}`, `<button class="leaf" data-go="trip:${t.trip_id}">
          <span class="k">${strecke(t)}
            <small>${dedat(new Date(t.ts_start))} · ${artText(t.art)}</small></span>
          <span class="v">${t.distance_km != null ? de(t.distance_km) + " km" : "offen"}<small>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : t.status}</small></span>
          <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`); });
      h += `</div></div>`;
    });
    h += `</div>`;
  });
  return h + `</div>
    ${vTripsFormular()}`;
}

function vTrip(id) {
  const t = TRIPS.find((x) => x.trip_id === id);
  if (!t) return `<div class="tile">Fahrt nicht gefunden</div>`;

  if (fahrtBearbeitenOffen) {
    return `
    <div class="tile"><span class="label">Fahrt bearbeiten</span>
      ${fahrtFelder("bearbFahrt", t)}
      <button class="aktion primaer" data-fahrtaktualisieren="${t.trip_id}">Speichern</button>
      <button class="aktion" data-fahrtbearbeiten>Abbrechen</button>
    </div>`;
  }

  return `
  ${mapBox()}
  <div class="tile flat" style="margin:18px 0 14px">
    <span class="label">Distanz</span>
    <div class="fig" style="font-size:52px;margin-top:10px">${t.distance_km != null ? de(t.distance_km) : "–"}<span class="unit">km</span></div>
    <div style="margin-top:15px">
      <button class="pill" data-art="${t.trip_id}"
        style="cursor:pointer;font-family:inherit">${artText(t.art)}</button></div>
  </div>
  <div class="tile"><dl class="rows">
    <div class="row"><dt>Startort</dt><dd>${esc(t.start_address || "unbekannt")}<small>${dedat(new Date(t.ts_start))}</small></dd></div>
    <div class="row"><dt>Zielort</dt><dd>${esc(t.end_address || "unbekannt")}</dd></div>
    <div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
    <div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
    <div class="row"><dt>Dauer</dt><dd>${Math.round(t.duration_s / 60)} min</dd></div>
    <div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
    <div class="row"><dt>Status</dt><dd>${t.status}<small>Quelle: ${t.km_quelle || "–"}</small></dd></div>
  </dl>
  <button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
  </div>
  <button class="aktion loeschen" data-tripdel="${t.trip_id}">Fahrt löschen</button>`;
}

function quad(t, v) { return `<span class="label" style="margin-top:20px">${t}</span>
  <div class="quad">${v.map((x) => `<div><div class="n">${x[0]}</div><div class="l">${x[1]}</div></div>`).join("")}</div>`; }
function statTile(id, label, fig, unit, extra, body) {
  return `<div class="tile"><button class="tile-h" aria-expanded="false" data-acc="${id}">
    <span class="label">${label}</span>
    <div style="display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-top:12px">
      <div class="fig" style="font-size:34px">${fig}<span class="unit">${unit}</span></div>
      <span class="wert" style="padding-bottom:5px">${extra}</span></div>
  </button><div class="tile-b" id="${id}">${body}</div></div>`;
}
/* 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)}&nbsp;→ ${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) : "–",
    `<dl class="rows">
       <div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
       <div class="row"><dt>Strecke bis zum letzten Tankstop</dt><dd>${de(lv.km)} km<small>${lv.basis}</small></dd></div>
       <div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
     </dl>
     ${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
     ${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
     <span class="label" style="margin-top:16px">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.</span>`)}
  ${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
    `<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
     <div class="legend"><span><i style="background:var(--fg)"></i>Tag · ${de(tagKm)} km</span>
       <span><i style="background:var(--fg3)"></i>Nacht · ${de(nachtKm)} km</span></div>
     <span class="label" style="margin-top:16px">Nacht = ${CONFIG.nachtVon} bis ${CONFIG.nachtBis} Uhr</span>`)}
  ${statTile("s5", "Art der Fahrten", kmJahr > 0 ? de(Math.round((arbeitKm / kmJahr) * 100)) : "–", "% Arbeitsweg", de(arbeitKm) + " km",
    `<div class="bar"><i style="flex:${arbeitKm};background:var(--fg)"></i><i style="flex:${privatKm};background:var(--fg3)"></i></div>
     <div class="legend"><span><i style="background:var(--fg)"></i>Arbeitsweg · ${de(arbeitKm)} km</span>
       <span><i style="background:var(--fg3)"></i>Privat · ${de(privatKm)} km</span></div>
     <dl class="rows" style="margin-top:18px">
       <div class="row"><dt>Fahrten Arbeitsweg</dt><dd>${de(arbeitFahrten.length)}</dd></div>
       <div class="row" style="border-bottom:0"><dt>Fahrten privat</dt><dd>${de(privatFahrten.length)}</dd></div></dl>`)}`;
}

let neuOffen = false;
let fahrtOffen = false;
let tankBearbeitenOffen = false;
let fahrtBearbeitenOffen = false;
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
let belegEntwurf = null;
// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"):
// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder
// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war.
let belegFehler = null;
// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher
// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine
// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null).
let belegZielPraefix = null;
// Offenes Beleg-Fenster: { tankId, kontext, fehler }. Am Rechner wird der Beleg
// hineingezogen, am Telefon aus der Zwischenablage eingefügt (aus der Mail
// kopiertes PDF) - der Dateiauswahl-Knopf bleibt als dritter Weg erhalten.
let belegPopup = null;

/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog
   zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit
   einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über
   Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events),
   damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben
   "art:id"-Schema wie data-go, siehe eintragLoeschen(). */
let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0;
let swipeWarGezogen = false;
const SWIPE_BREITE = 84;

/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des
   Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene
   Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig
   voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer
   Listenzeile UND ganz oben gescrollt). */
let ptrY0 = null, ptrDrag = 0, ptrLaedt = false;
async function datenAktualisieren() {
  try {
    await HASS.callService(DOMAIN, "jetzt_aktualisieren", {});
    const states = await HASS.callWS({ type: "get_states" });
    const frisch = states.find((s) => s.entity_id === E.fahrzeugstatus);
    if (frisch) NACHGELADEN.status = frisch;
  } catch (e) {
    // bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt
    // holt den Stand ohnehin nach
  }
  datenLaden(true);
}
function schliesseAlleSwipes() {
  ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped"));
}
function swipeZeile(id, inhaltHtml) {
  return `<div class="swipe-wrap" data-swipe="${id}">
    <div class="swipe-delete"><button class="swipe-loeschen" data-swipe-loeschen="${id}">Löschen</button></div>
    <div class="swipe-content">${inhaltHtml}</div>
  </div>`;
}
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("fahrt_loeschen", { trip_id: id });
    TRIPS = TRIPS.filter((t) => t.trip_id !== id);
  } else if (art === "fill") {
    serviceRufen("tankvorgang_loeschen", { tank_id: id });
    FILLS = FILLS.filter((f) => f.tank_id !== id);
  } else if (art === "sbuch") {
    CAR.service.buch.splice(parseInt(id, 10), 1);
    profilSpeichern();
  }
}

/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht
   bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite
   offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern
   und den Verwerfen-Schritt in go(). */
let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];

/* Welche Fassung der Integration gerade installiert ist (Einstellungen ->
   Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der
   manifest.json der Integration.

   Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen"
   klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/
   und www/ von dort über den laufenden Code. Das war nötig, solange die App
   keine Installationsform hatte, die Home Assistant selbst kennt - jetzt
   gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin
   inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS
   scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md
   Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre
   mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb
   ersatzlos entfallen. */
let APP_VERSION = null;
// Ergebnis der letzten "Auf Update prüfen"/"Update installieren"
// (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
let INTEGRATION_UPDATE = null;
// "pruefen" | "installieren" | "neustart" | null - während einer dieser drei
// Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt
// über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen
// unbestimmt lange dauern (Netzwerk zu Gitea, Entpacken) und die Oberfläche
// das sichtbar machen soll - siehe .lade-spinner's eigener Kommentar dazu.
let INTEGRATION_UPDATE_LAEUFT = null;
// Nur einmal die "ready"-Verbindung des HA-Websockets belauschen (siehe
// deren Verdrahtung in set hass() unten) - HASS wird bei jedem Tick neu
// zugewiesen, ohne diese Sperre würde sich sonst pro hass-Push ein weiterer
// Listener aufhäufen.
let VERBINDUNG_UEBERWACHT = false;

/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
   (Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
   Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details
   und die technischen Daten hängen bewusst nicht daran - die bleiben, auch
   bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen.
   Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe
   BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches
   oder gar kein Badge auf der Übersicht. */
const FAHRZEUG_MODELLE = [
  "RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant",
  "RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance",
  "RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7",
];

/* Badge-Zuordnung (www/badges/<datei>-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, 2)} €` : "Preis je Liter: wird berechnet";
  return `
      <div class="feld"><label>Datum</label><input type="date" id="${praefix}Datum" value="${datum}"></div>
      <div class="feld"><label>Uhrzeit</label><input type="time" id="${praefix}Zeit" value="${zeit}"></div>
      <div class="feld"><label>Kilometerstand</label><input type="number" id="${praefix}Km" value="${km}"></div>
      <div class="feld"><label>Gefahrene Distanz</label>
        <span class="mitEinheit"><input type="number" step="1" id="${praefix}Distanz" value="${distanz}" placeholder="0"><span>km</span></span></div>
      <div class="feld"><label>Kraftstoff</label>
        <select id="${praefix}Kraftstoff">${KRAFTSTOFFSORTEN.map((k) => `<option ${k === kraftstoff ? "selected" : ""}>${k}</option>`).join("")}</select></div>
      <div class="feld"><label>Getankte Liter</label><input type="number" step="0.01" id="${praefix}Liter" data-tankpreisfeld="${praefix}" value="${liter}" placeholder="0,00"></div>
      <div class="feld"><label>Kosten (real, nach Rabatt)</label>
        <span class="mitEinheit"><input type="number" step="0.01" id="${praefix}Kosten" data-tankpreisfeld="${praefix}" value="${kosten}" placeholder="0,00"><span>€</span></span></div>
      ${CAR.smartdeal.aktiv ? `<div class="feld"><label>SmartDeal-Ersparnis</label>
        <span class="mitEinheit"><input type="number" step="0.01" id="${praefix}Ersparnis" value="${ersparnis}" placeholder="0,00"><span>€</span></span></div>` : ""}
      <div class="feld" style="border-bottom:0"><label>Tankstelle</label><input id="${praefix}Station" value="${esc(station)}" placeholder="Name"></div>
      <span class="label" style="margin-top:14px" id="${praefix}PreisAnzeige">${preisText}</span>`;
}

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 ? `<div class="tile"><span class="label">Neuer Tankvorgang</span>
      ${tankFelder("neuTank", null)}
      ${belegFehler && belegFehler.context === "neuTank" ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(belegFehler.meldung)}</span>` : ""}
      <button class="aktion" data-beleg="neu">Beleg hochladen</button>
      <button class="aktion primaer" data-tankspeichernbeleg>Speichern</button>
      <button class="aktion" data-tankspeichern>Ohne Beleg speichern</button></div>` : ""}
    <button class="aktion ${neuOffen ? "" : "primaer"}" data-neu>${neuOffen ? "Abbrechen" : "Tankvorgang erfassen"}</button>`;
}
function vFuel() {
  const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a);
  /* D5 */
  if (!jahre.length) return `<div class="tile"><div class="leer">
      <strong>Noch keine Tankvorgänge</strong>
      <p>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.</p>
    </div></div>
    ${vFuelFormular()}`;
  let h = `<div class="tile">`;
  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 += `<button class="acc-h" aria-expanded="${ji === 0}" data-acc="fy${j}"><span class="mark"></span>
      <span class="k">${j}</span>
      <span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
        <span style="font-size:17px;color:var(--fg)">${eur(sum(fj, (f) => f.fuel_total_eur || 0))} €</span>
        <span>${de(sum(fj, (f) => f.liters || 0), 1)} l · Ø ${de(schnittpreis(fj), 2)} €/l</span>
        ${CAR.smartdeal.aktiv ? `<span class="marke"><span class="pill" style="font-size:11.5px;padding:4px 10px">${markenlogo()}<span style="margin-left:6px">−${eur(ersparnisJahr)} €</span></span></span>` : ""}
      </span></button>
      <div class="acc-b ${ji === 0 ? "on" : ""}" id="fy${j}">`;
    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 += `<div class="lvl2"><button class="acc-h" aria-expanded="${mi === 0}" data-acc="fm${j}${m}">
        <span class="mark"></span><span class="k">${m}</span>
        <span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
          <span style="color:var(--fg)">${eur(sum(fm, (f) => f.fuel_total_eur || 0))} €</span>
          <span>Ø ${de(schnittpreis(fm), 2)} €/l</span></span></button>
        <div class="acc-b ${mi === 0 ? "on" : ""}" id="fm${j}${m}">`;
      fm.forEach((f) => { const preis = f.liters ? f.fuel_total_eur / f.liters : null; h += swipeZeile(`fill:${f.tank_id}`, `<button class="leaf" data-go="fill:${f.tank_id}">
        <span class="k">${dedat(new Date(f.ts))}<small>${esc(f.station_name || "")}</small></span>
        <span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${preis != null ? de(preis, 2) + " €/l" : ""}</small></span>
        <svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`); });
      h += `</div></div>`;
    });
    h += `</div>`;
  });
  return h + `</div>
    ${vFuelFormular()}`;
}

function vFill(id) {
  const f = FILLS.find((x) => x.tank_id === id);
  if (!f) return `<div class="tile">Tankvorgang nicht gefunden</div>`;
  const berechneterPreis = f.liters ? f.fuel_total_eur / f.liters : null;

  if (tankBearbeitenOffen) {
    return `
    <div class="tile"><span class="label">Tankvorgang bearbeiten</span>
      ${tankFelder("bearbTank", f)}
      ${belegFehler && belegFehler.context === "bearbTank" ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(belegFehler.meldung)}</span>` : ""}
      <button class="aktion" data-beleg="bearb">Beleg hochladen</button>
      <button class="aktion" data-tankaktualisieren="${f.tank_id}">Speichern</button>
      <button class="aktion" data-tankbearbeiten>Abbrechen</button>
    </div>`;
  }

  return `
  ${mapBoxMitZentrieren()}
  <div class="tile flat" style="margin:18px 0 14px">
    <span class="label">Getankt</span>
    <div class="fig" style="font-size:52px;margin-top:10px">${f.liters != null ? de(f.liters, 2) : "–"}<span class="unit">l</span></div>
  </div>
  <div class="tile"><dl class="rows rows-fest">
    <div class="row"><dt>Zeitpunkt</dt><dd>${dedat(new Date(f.ts))}<small>${new Date(f.ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr</small></dd></div>
    <div class="row"><dt>Kilometerstand</dt><dd>${f.odometer_km != null ? de(f.odometer_km) + " km" : "unbekannt"}</dd></div>
    <div class="row"><dt>Gefahrene Distanz</dt><dd>${f.distance_km != null ? de(f.distance_km) + " km" : "unbekannt"}<small>seit der letzten Tankung</small></dd></div>
    <div class="row"><dt>Kraftstoff</dt><dd>${esc(f.fuel_type || KRAFTSTOFFSORTEN[0])}</dd></div>
    <div class="row"><dt>Getankte Liter</dt><dd>${f.liters != null ? de(f.liters, 2) + " l" : "–"}</dd></div>
    <div class="row"><dt>Kosten</dt><dd>${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}<small>real, nach Rabatt</small></dd></div>
    <div class="row"><dt>Preis je Liter</dt><dd>${berechneterPreis != null ? de(berechneterPreis, 2) + " €" : "–"}<small>Kosten ÷ Liter</small></dd></div>
    ${CAR.smartdeal.aktiv && f.discount != null ? `<div class="row"><dt><span class="marke">${markenlogo()} SmartDeal-Ersparnis</span></dt><dd>${eur(f.discount)} €</dd></div>` : ""}
    <div class="row" style="border-bottom:0"><dt>Tankstelle</dt><dd>${esc(f.station_name || "unbekannt")}<small>${esc(f.station_address || "")}</small></dd></div>
  </dl>
  ${belegFehler && belegFehler.context === f.tank_id ? `<span class="label" style="margin-top:14px;color:var(--red)">${esc(belegFehler.meldung)}</span>` : ""}
  <button class="aktion" data-tankbearbeiten>Werte bearbeiten</button>
  <button class="aktion" data-beleg="${f.tank_id}">${f.receipt_file ? "Beleg ersetzen" : "Beleg nachtragen"}</button>
  <button class="aktion loeschen" data-filldel="${f.tank_id}">Tankvorgang löschen</button>
  </div>`;
}

/* ---------------------------------------------------------- Steuerung */
const ICONS = {
  home: '<path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/>',
  /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
     assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
  audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
  trips: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5"/>',
  stat: '<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2"/>',
  fuel: '<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20"/><path d="M3 20h12.5"/><path d="M4.5 11h10.5"/><path d="M17 20v-8.5a2 2 0 0 0-2-2"/><path d="M17 11.5V7.8l-2-2.3"/>',
};
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",
  vertrag: "vers",
  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",
  tuerenklappen: "sicherheit",
};
const ICON = {
  sonne: '<circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.9 3.9l1.4 1.4M14.7 14.7l1.4 1.4M16.1 3.9l-1.4 1.4M5.3 14.7l-1.4 1.4"/>',
  mond: '<path d="M16.5 12.4A7 7 0 1 1 7.6 3.5a5.6 5.6 0 0 0 8.9 8.9z"/>',
};
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();
}
/* ------------------------------------------------------- Beleg-Upload
   Ein Weg für alle drei Quellen (Ziehen, Einfügen, Dateiauswahl). Kein
   serviceRufen() hier: dessen catch loggt nur in die Konsole - siehe den
   ausführlichen Kommentar an der früheren Aufrufstelle, der Grund gilt
   unverändert. */
function belegDateiVerarbeiten(datei, tankId, kontext) {
  if (!datei) return;
  if (!istPdf(datei)) {
    belegFehler = { context: kontext, meldung: "Nur PDF-Belege werden unterstützt" };
    render(); return;
  }
  const leser = new FileReader();
  leser.onload = () => {
    const base64 = leser.result.split(",")[1];
    HASS.callService(DOMAIN, "beleg_hochladen",
      { pdf_base64: base64, dateiname: datei.name || "beleg.pdf", tank_id: tankId }
    ).catch((err) => {
      console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err);
      belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" };
      if (!tankId) belegEntwurf = null;
      render();
    });
  };
  leser.readAsDataURL(datei);
}

/* Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
   oder gezogene Datei kommt je nach Browser und Quelle mit leerem type an. */
function istPdf(datei) {
  if (!datei) return false;
  return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "");
}

/* Nimmt das erste PDF aus einer DataTransfer-/Clipboard-Dateiliste. */
function belegAusDateiliste(dateien) {
  return [...(dateien || [])].find(istPdf) || null;
}

function belegPopupSchliessenUndLaden(datei) {
  const ctx = belegPopup;
  if (!ctx || !datei) return;
  belegPopup = null;
  belegDateiVerarbeiten(datei, ctx.tankId, ctx.kontext);
  render();
}

/* Telefon-Weg: das aus der Mail kopierte PDF liegt in der Zwischenablage.
   navigator.clipboard.read() ist dort der einzige Weg, ohne dass der Nutzer
   in ein Eingabefeld tippen muss - iOS zeigt dafür seine eigene
   Einfügen-Abfrage. Der Knopf erscheint deshalb nur auf Tippgeräten, siehe
   zwischenablageKnopfSinnvoll(). */
async function belegAusZwischenablage() {
  const ctx = belegPopup;
  if (!ctx) return;
  if (!navigator.clipboard || !navigator.clipboard.read) {
    belegPopup.fehler = "Dieser Browser gibt die Zwischenablage nicht heraus - bitte \"Datei auswählen\".";
    render(); return;
  }
  try {
    for (const eintrag of await navigator.clipboard.read()) {
      const typ = (eintrag.types || []).find((t) => t === "application/pdf");
      if (typ) { belegPopupSchliessenUndLaden(new File([await eintrag.getType(typ)], "beleg.pdf", { type: "application/pdf" })); return; }
    }
    belegPopup.fehler = "In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder \"Datei auswählen\".";
  } catch (err) {
    belegPopup.fehler = "Kein Zugriff auf die Zwischenablage - bitte \"Datei auswählen\".";
  }
  render();
}

/* Der Einfügen-Knopf wird nur auf Tippgeräten angeboten. Am Rechner scheitert
   er zwangsläufig: clipboard.read() gibt aus Datenschutzgründen nur Text,
   HTML und Bilder heraus, eine im Explorer kopierte Datei taucht dort nie
   auf. Und das Tastenkürzel Strg+V hilft dort ebenfalls nicht - ohne
   Eingabefeld im Fenster hat der Browser kein Ziel für die Einfüge-Geste
   (vom Nutzer bestätigt). Am Rechner bleiben daher Ziehen und Dateiauswahl. */
function zwischenablageKnopfSinnvoll() {
  return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}

/* ------------------------------------------------- Historien-Import-Popup
   Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
   Import läuft im Backend (custom_components/audi_dashboard/historienimport.py)
   und meldet seinen Stand über die Entität sensor.audi_dashboard_import_status:
   der Dienstaufruf kehrt sofort zurück, weil ein Import über ein Jahr Verlauf
   Minuten dauern kann. */
let importPopup = null;

/* Vorbelegung: die letzten 30 Tage. Wert im Format, das <input
   type="datetime-local"> erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
   genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
  const jetzt = new Date();
  const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
  const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
    .toISOString().slice(0, 16);
  return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}

function importErgebnisText(d) {
  const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
  const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
  if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
  if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
  if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
  if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
  return teile.join(" · ");
}

function vImportPopup() {
  if (!importPopup) return "";
  const p = importPopup;
  const fertig = p.ergebnis && !p.laeuft;
  // Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
  // Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
  // "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
  const koerper = fertig
    ? `<div style="margin-top:14px;font-size:14px;line-height:1.6;color:var(--fg)">
         ${esc(importErgebnisText(p.ergebnis))}</div>
       ${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
         ? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
              text-transform:none;font-size:12.5px;line-height:1.6">
              Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
              ${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
              Weiter zurück bewahrt Home Assistant nichts mehr auf.</span>`
         : ""}
       ${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
         ? `<span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
              text-transform:none;font-size:12.5px;line-height:1.6">
              Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.</span>`
         : ""}`
    : `<div class="feld" style="margin-top:12px"><label for="impVon">Von</label>
         <input id="impVon" type="datetime-local" style="width:196px" value="${esc(p.von)}" data-import-von></div>
       <div class="feld" style="border-bottom:0"><label for="impBis">Bis</label>
         <input id="impBis" type="datetime-local" style="width:196px" value="${esc(p.bis)}" data-import-bis></div>
       ${p.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(p.fehler)}</span>` : ""}
       <span class="label" style="margin-top:12px;color:var(--fg2);letter-spacing:0;
         text-transform:none;font-size:12.5px;line-height:1.6">
         Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein
         Zeitraum, wird er übersprungen statt doppelt angelegt.</span>`;

  return `<div class="beleg-catcher" data-import-zu></div>
  <div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Daten importieren">
    <span class="label">Daten importieren aus Home Assistant</span>
    ${koerper}
    ${fertig
      ? `<button class="aktion primaer" data-import-zu>Fertig</button>`
      : `<button class="aktion primaer" data-import-start ${p.laeuft ? "disabled" : ""}>${p.laeuft ? "Importiere …" : "Importieren"}</button>
         <button class="aktion" data-import-zu ${p.laeuft ? "disabled" : ""}>Abbrechen</button>`}
  </div>`;
}

function vBelegPopup() {
  if (!belegPopup) return "";
  // Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
  // Rechner Ziehen bzw. Dateiauswahl. Strg+V wird am Rechner bewusst NICHT
  // mehr angeboten: ohne Eingabefeld im Fenster hat der Browser kein Ziel für
  // die Einfüge-Geste, das Tastenkürzel läuft dort also ins Leere.
  const telefon = zwischenablageKnopfSinnvoll();
  return `<div class="beleg-catcher" data-belegzu></div>
  <div class="beleg-popup" role="dialog" aria-modal="true" aria-label="Beleg hochladen">
    <span class="label">Beleg hochladen</span>
    <div class="beleg-drop" id="belegDrop">
      ${telefon
        ? `<strong>Beleg einfügen</strong><span>das aus der Mail kopierte PDF</span>`
        : `<strong>PDF hierher ziehen</strong><span>oder unten auswählen</span>`}
    </div>
    ${belegPopup.fehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(belegPopup.fehler)}</span>` : ""}
    ${telefon ? `<button class="aktion primaer" data-belegeinfuegen>Aus Zwischenablage einfügen</button>` : ""}
    <button class="aktion ${telefon ? "" : "primaer"}" data-belegdatei>Datei auswählen</button>
    <button class="aktion" data-belegzu>Abbrechen</button>
  </div>`;
}

function sheetMarkup() {
  if (!sheet) return "";
  const kopf = `<div class="sheet-kopf"><strong>${esc(sheet.titel)}</strong>${sheet.text ? `<span>${esc(sheet.text)}</span>` : ""}</div>`;
  const aktion = sheet.knopf
    ? `<button data-sheet-ok class="${sheet.destruktiv ? "loeschen" : ""}">${esc(sheet.knopf)}</button>`
    : `<button data-sheet-abbrechen>Verstanden</button>`;
  return `<div class="sheet-catcher" data-sheet-abbrechen></div>
  <div class="sheet" role="dialog" aria-modal="true">
    <div class="sheet-gruppe">${kopf}${aktion}</div>
    ${sheet.knopf ? `<button class="sheet-abbrechen" data-sheet-abbrechen>Abbrechen</button>` : ""}
  </div>`;
}

/* ------------------------------------------------------------ Setup-Popup */
const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="6"/><path d="m17 17-4.3-4.3"/></svg>`;

function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) {
  const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert);
  if (!kandidaten.length) return `<div class="entitaet-leer">Keine passende Entität gefunden</div>`;
  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 `
    <button type="button" class="entitaet-eintrag ${e.id === aktuellerWert ? "aktiv" : ""}" data-setup-wahl="${esc(feldId)}" data-setup-entity="${esc(e.id)}">
      <span class="entitaet-eintrag-name">${esc(e.name)}</span>
      <span class="entitaet-eintrag-id">${esc(e.id)}</span>
      <span class="entitaet-eintrag-wert ${unbekannt ? "unbekannt" : ""}">${esc(wertAnzeige)}</span>
    </button>`;
  }).join("");
}

const RESET_SVG = `<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M16 10a6 6 0 1 1-2-4.5"/><path d="M16 3v4h-4"/></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 `<div class="entitaet-combo">
    <span class="entitaet-combo-icon">${LUPE_SVG}</span>
    <input type="text" class="entitaet-combo-input" autocomplete="off" spellcheck="false"
      placeholder="Entität suchen …" value="${esc(anzeige)}" data-setup-suche="${esc(feldId)}">
    ${zeigeReset ? `<button type="button" class="entitaet-combo-reset" title="Auf Standard zurücksetzen" data-setup-reset="${esc(feldId)}">${RESET_SVG}</button>` : ""}
    ${offen ? `<div class="entitaet-liste" data-setup-liste="${esc(feldId)}">${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}</div>` : ""}
  </div>`;
}

function setupFeldzeile(feld) {
  const kopf = `<div class="setup-feld-kopf">
      <span class="setup-feld-label">${esc(feld.label)}</span>
      <span class="setup-feld-hinweis">${esc(feld.hinweis)}</span>
    </div>`;
  if (feld.liste) {
    return `<div class="setup-feldzeile">${kopf}
      <div class="setup-unterfelder">
        ${feld.positionen.map((pos, i) => `
          <div class="setup-unterfeld">
            <span class="setup-unterfeld-label">${esc(pos)}</span>
            ${entitaetCombo(feld.key, i)}
            ${entitaetStatusWarnung((setupZuordnung[feld.key] || [])[i])}
          </div>`).join("")}
      </div>
    </div>`;
  }
  return `<div class="setup-feldzeile">${kopf}${entitaetCombo(feld.key, null)}${entitaetStatusWarnung(setupZuordnung[feld.key])}</div>`;
}

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 `<div class="setup-catcher" data-setup-abbrechen></div>
  <div class="setup-popup" role="dialog" aria-modal="true" aria-label="Setup">
    <div class="setup-kopf">
      <strong>Setup</strong>
      <span>Sensoren dieser Home-Assistant-Instanz den App-Funktionen zuordnen.</span>
      <div class="setup-kopf-schalter">
        <label class="switch"><input type="checkbox" data-setup-nurpassend aria-label="Nur passende Sensoren anzeigen" ${setupNurPassend ? "checked" : ""}><span class="track"></span></label>
        <span aria-hidden="true">Nur passende Sensoren anzeigen</span>
      </div>
    </div>
    <div class="setup-koerper">
      ${SETUP_GRUPPEN.map(([key, titel]) => {
        const felder = katalog.filter((f) => f.gruppe === key);
        if (!felder.length) return "";
        return `<div class="setup-gruppe">
          <div class="setup-gruppe-titel">${esc(titel)}</div>
          ${felder.map((feld) => setupFeldzeile(feld)).join("")}
        </div>`;
      }).join("")}
    </div>
    <div class="setup-fuss">
      <button type="button" class="aktion" data-setup-abbrechen>Abbrechen</button>
      <button type="button" class="aktion primaer" data-setup-speichern>${setupSpeichertLaeuft ? "Speichert …" : "Speichern"}</button>
    </div>
  </div>`;
}

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", "Fahrzeugstatus"]; v.innerHTML = vSicherheit(); }
  else if (route.name === "tuerenklappen") { head = ["Fahrzeug", "Türen und Klappen"]; v.innerHTML = vTuerenKlappen(); }
  else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); }
  else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); }
  else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); }
  else if (route.name === "vertrag") { head = ["Versicherung", "Vertrag"]; v.innerHTML = vVertragBearbeiten(); }
  else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); }
  else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); }
  else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); }
  else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); }
  else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); }
  else if (route.name === "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() + vBelegPopup() + vImportPopup();
  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) => {
  // Bekanntes Race (siehe Kopfkommentar bei nachladeAnstossen): auf einem
  // langsam startenden Backend kann die App auf "Lädt …" hängen bleiben,
  // obwohl die Daten inzwischen längst da wären - der bislang einzige
  // beobachtete Ausweg war ein Tabwechsel, der zufällig einen frischen
  // datenLaden()-Durchlauf auslöste. Hier deshalb bei jedem Tab-/Menüklick
  // aktiv nachschauen, statt nur auf den nächsten hass-Push zu warten -
  // no-op, falls die Daten längst da sind (datenLaden() rendert dann
  // ohnehin nichts doppelt Sichtbares).
  if (!DATEN_GELADEN) datenLaden(false);
  // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die
  // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf
  // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen
  // und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren.
  if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) {
    CAR.service.buch.splice(entwurfIndex, 1);
    entwurfIndex = null;
  }
  sheet = null;
  // Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es
  // geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern.
  fahrtBearbeitenOffen = false;
  /* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt
     den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */
  if (istTab(name)) routeStack = [];
  else if (route.name !== name || route.id !== id) routeStack.push(route);
  navRichtung = istTab(name) ? null : "rein";
  route = { name, id }; render();
};
function zurueck() {
  sheet = null;
  fahrtBearbeitenOffen = false;
  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. */
/* Import anstoßen und auf sein Ergebnis warten.

   Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der
   das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu
   schließen. Das Backend meldet seinen Stand über
   sensor.audi_dashboard_import_status; hier wird dieselbe Entität gepollt,
   bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des
   callService-Versprechens, weil der Import-Dienst sofort zurückkehrt, wenn
   sie im Hintergrund weiterlaufen. */
async function importStatusLesen() {
  try {
    const states = await HASS.callWS({ type: "get_states" });
    return states.find((x) => x.entity_id === E.importStatus) || null;
  } catch (e) {
    return null;
  }
}

async function importAusloesen() {
  if (!importPopup || importPopup.laeuft) return;
  const von = importPopup.von;
  const bis = importPopup.bis;
  if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; }
  if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; }

  importPopup.laeuft = true;
  importPopup.fehler = null;
  render();

  // Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand
  // ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern.
  const vorher = await importStatusLesen();
  const vorherStand = vorher ? vorher.last_updated : null;

  try {
    await HASS.callService(DOMAIN, "historie_importieren", { start: von, ende: bis });
  } catch (err) {
    console.error("audi_dashboard: Import", err);
    if (importPopup) {
      importPopup.laeuft = false;
      importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden.";
      render();
    }
    return;
  }

  // Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine
  // Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der
  // Import im Hintergrund sauber zu Ende läuft.
  for (let versuch = 0; versuch < 120; versuch++) {
    await new Promise((r) => setTimeout(r, 1000));
    if (!importPopup) return;
    const s = await importStatusLesen();
    if (!s || s.last_updated === vorherStand) continue;
    if (s.state === "fertig") {
      importPopup.laeuft = false;
      importPopup.ergebnis = (s.attributes && s.attributes.daten) || {};
      render();
      // Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat
      // sie bereits neu veröffentlicht, die App muss sie nur neu lesen.
      datenAktualisieren();
      return;
    }
    if (s.state === "fehler") {
      importPopup.laeuft = false;
      importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung)
        || "Der Import ist fehlgeschlagen.";
      render();
      return;
    }
  }
  if (importPopup) {
    importPopup.laeuft = false;
    importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter.";
    render();
  }
}

function serviceRufen(dienst, daten) {
  // HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole
  // landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und
  // die UI optimistisch aktualisiert, sonst würde die Änderung lautlos
  // verloren gehen (derselbe Fehler, der für den Beleg-Upload und das Setup-
  // Speichern schon einmal einzeln gefixt wurde - hier zentral für alle
  // ~20 Aufrufstellen).
  HASS.callService(DOMAIN, dienst, daten).catch((err) => {
    console.error("audi_dashboard:", dienst, err);
    hinweis("Aktion fehlgeschlagen", err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden.");
  });
}

function ereignisseVerdrahten() {
  /* Tastaturbedienung fuer die beiden Ueberlagerungen. Bisher gab es in der
     ganzen Datei keinen keydown-Handler: Das Setup-Fenster deklariert
     role="dialog" aria-modal="true", liess sich aber weder mit Escape
     schliessen noch verhinderte es, dass der Tabulator in den verdeckten
     Hintergrund wandert. Beides gehoert zum Vertrag dieser Rolle.
     Der Handler haengt am Dokument, weil Fokus im Shadow DOM auch ausserhalb
     von ROOT liegen kann. */
  /* Ziehen und Ablegen (Rechner) sowie Einfügen per Tastatur, solange das
     Beleg-Fenster offen ist. Die Listener hängen am Dokument, weil ein Zug
     über der Ablagefläche im Shadow DOM sonst schon am Rand verloren geht -
     und weil ohne preventDefault auf dragover der Browser das PDF einfach
     selbst öffnet, statt ein drop-Ereignis zu liefern. */
  document.addEventListener("dragover", (e) => {
    if (!belegPopup) return;
    e.preventDefault();
    const zone = ROOT.getElementById("belegDrop");
    if (zone) zone.classList.add("darueber");
  });
  document.addEventListener("dragleave", (e) => {
    if (!belegPopup || e.relatedTarget) return;
    const zone = ROOT.getElementById("belegDrop");
    if (zone) zone.classList.remove("darueber");
  });
  document.addEventListener("drop", (e) => {
    if (!belegPopup) return;
    e.preventDefault();
    const datei = belegAusDateiliste(e.dataTransfer && e.dataTransfer.files);
    if (datei) { belegPopupSchliessenUndLaden(datei); return; }
    belegPopup.fehler = "Das war kein PDF.";
    render();
  });
  /* Zugabe, kein beworbener Weg: wo der Browser eine Einfüge-Geste ohne
     Eingabefeld überhaupt zustellt, wird sie mitgenommen. Am Rechner tut
     Chrome das nicht (deshalb steht Strg+V nirgends mehr als Hinweis), es
     kostet aber nichts, das Ereignis trotzdem anzunehmen. */
  document.addEventListener("paste", (e) => {
    if (!belegPopup) return;
    const datei = belegAusDateiliste(e.clipboardData && e.clipboardData.files);
    if (datei) { e.preventDefault(); belegPopupSchliessenUndLaden(datei); }
  });
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape") {
      if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; }
      // Während der Import läuft nicht wegklickbar - er läuft im Backend
      // weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt.
      if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; }
      if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
      if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
      if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
      return;
    }
    if (e.key !== "Tab" || !setupOffen) return;
    /* Fokusfalle: innerhalb des Fensters im Kreis wandern statt dahinter. */
    const fokussierbar = ROOT.querySelectorAll(
      '.setup-popup button:not([disabled]), .setup-popup input:not([disabled]), .setup-popup [tabindex]:not([tabindex="-1"])');
    if (!fokussierbar.length) return;
    const erstes = fokussierbar[0];
    const letztes = fokussierbar[fokussierbar.length - 1];
    const aktiv = ROOT.activeElement;
    if (e.shiftKey && (aktiv === erstes || !aktiv)) { letztes.focus(); e.preventDefault(); }
    else if (!e.shiftKey && aktiv === letztes) { erstes.focus(); e.preventDefault(); }
  });
  ROOT.getElementById("tabbar").innerHTML = TABS.map((t) =>
    `<button class="tab" data-tab="${t.id}" aria-current="false">
       <span class="tabpille"><svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true"${ICONS_VOLL.has(t.id) ? ' class="voll"' : ""}>${ICONS[t.id]}</svg></span><span>${t.label}</span>
     </button>`).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 theme-abhaengige, in JS gesetzte Werte (z. B. die
       aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst
       sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */
    render();
  });
  // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe
  // Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser).
  // touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ
  // zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier
  // preventDefault() die Kontrolle.
  ROOT.addEventListener("pointerdown", (e) => {
    // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) -
    // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist.
    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;
    // Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in
    // audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch.
    swipeAktivWrap.classList.add("wischt");
    const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0;
    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 = "";
    swipeAktivWrap.classList.remove("wischt");
    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(DOMAIN, "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");
      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");
      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.closest("[data-bildgalerie]")) { 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;
    }
    const kmauf = e.target.closest("[data-kmauf]");
    if (kmauf) { kmOffen = kmOffen === kmauf.dataset.kmauf ? null : kmauf.dataset.kmauf; render(); return; }
    const kmspeichern = e.target.closest("[data-kmspeichern]");
    if (kmspeichern) {
      const satz = kmspeichern.dataset.kmspeichern;
      const wert = parseFloat($(`#km-${satz}`).value);
      if (!isNaN(wert) && wert >= 0) {
        const km = Math.round(wert * 10) / 10;
        CAR.reifen[satz].km = km;
        // km wird bewusst nicht ueber profilSpeichern() geschrieben (siehe
        // Kommentar in configCarZuProfil) - eigener Service, der nur den
        // Zaehler selbst setzt und referenz_odo_km unangetastet laesst, damit
        // die naechste automatische Fortschreibung ab diesem neuen Stand
        // weiterzaehlt statt ihn zu ueberschreiben.
        serviceRufen("reifen_km_setzen", { satz, km });
      }
      kmOffen = null; render(); return;
    }
    if (e.target.closest("[data-neu]")) {
      neuOffen = !neuOffen;
      if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null;
      if (belegFehler && belegFehler.context === "neuTank") belegFehler = null;
      render(); return;
    }
    if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; }
    if (e.target.closest("[data-fahrtspeichern]")) {
      serviceRufen("fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt"));
      fahrtOffen = false; render(); return;
    }
    if (e.target.closest("[data-fahrtbearbeiten]")) {
      fahrtBearbeitenOffen = !fahrtBearbeitenOffen; render(); return;
    }
    const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]");
    if (fahrtAkt) {
      serviceRufen("fahrt_aktualisieren",
        { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") });
      fahrtBearbeitenOffen = false; render(); return;
    }
    if (e.target.closest("[data-tankspeichern]")) {
      const w = tankFormularWerte("neuTank");
      if (w.liter > 0 && w.kosten > 0) {
        serviceRufen("tankvorgang_manuell", {
          ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station,
          distanz: w.distanz, kraftstoff: w.kraftstoff,
        });
      }
      neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
    }
    if (e.target.closest("[data-tankspeichernbeleg]")) {
      const w = tankFormularWerte("neuTank");
      const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null;
      if (w.liter > 0 && w.kosten > 0) {
        serviceRufen("tankvorgang_manuell", {
          ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station,
          distanz: w.distanz, kraftstoff: w.kraftstoff,
          receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null,
        });
      }
      neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
    }
    if (e.target.closest("[data-tankbearbeiten]")) {
      tankBearbeitenOffen = !tankBearbeitenOffen;
      if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null;
      if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null;
      render(); return;
    }
    const tankAkt = e.target.closest("[data-tankaktualisieren]");
    if (tankAkt) {
      const w = tankFormularWerte("bearbTank");
      const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null;
      serviceRufen("tankvorgang_aktualisieren", {
        tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten,
        km: w.km, ersparnis: w.ersparnis, station: w.station,
        distanz: w.distanz, kraftstoff: w.kraftstoff,
        receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null,
      });
      tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
    }
    const belegBtn = e.target.closest("[data-beleg]");
    if (belegBtn) {
      // "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe
      // belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg-
      // Ansicht) trägt den Beleg direkt in den bereits gespeicherten
      // Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen.
      const ziel = belegBtn.dataset.beleg;
      const nurParsen = ziel === "neu" || ziel === "bearb";
      const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null;
      const tankId = nurParsen ? undefined : ziel;
      const kontext = nurParsen ? praefix : ziel;
      if (belegFehler && belegFehler.context === kontext) belegFehler = null;
      belegZielPraefix = praefix;
      // Statt direkt die Dateiauswahl zu öffnen: Fenster mit Ablagefläche
      // (Rechner) und Einfügen-Knopf (Telefon, PDF aus der Mail kopiert).
      belegPopup = { tankId, kontext, fehler: null };
      render();
      return;
    }
    if (e.target.closest("[data-belegzu]")) { belegPopup = null; render(); return; }
    if (e.target.closest("[data-belegeinfuegen]")) { belegAusZwischenablage(); return; }
    if (e.target.closest("[data-belegdatei]")) {
      const eingabe = document.createElement("input");
      eingabe.type = "file"; eingabe.accept = "application/pdf";
      eingabe.onchange = () => belegPopupSchliessenUndLaden(eingabe.files[0]);
      eingabe.click();
      return;
    }
    if (e.target.closest("[data-import-oeffnen]")) {
      const vorgabe = importZeitraumVorgabe();
      importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null };
      render();
      return;
    }
    if (e.target.closest("[data-import-zu]")) {
      if (importPopup && importPopup.laeuft) return;
      importPopup = null; render(); return;
    }
    if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
    const del = e.target.closest("[data-loeschen]");
    if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
    const vseldel = e.target.closest("[data-vselbstdel]");
    if (vseldel) { CAR.versicherung.selbst.splice(+vseldel.dataset.vselbstdel, 1); profilSpeichern(); render(); return; }
    if (e.target.closest("[data-vselbstadd]")) { CAR.versicherung.selbst.push(["", ""]); profilSpeichern(); render(); return; }
    const oel = e.target.closest("[data-oel]");
    if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
    const mode = e.target.closest("[data-mode]");
    if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
    const 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("backup_jetzt", {}); return; }
    if (bk && bk.dataset.backup === "export") {
      // Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in
        // entitaeten.json und wäre nach einer Wiederherstellung verloren.
        const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS,
          entitaeten: (ENTITAETEN && ENTITAETEN.werte) || null };
      const a = document.createElement("a");
      a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2));
      a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
      return;
    }
    const upd = e.target.closest("[data-update]");
    if (upd) {
      const aktion = upd.dataset.update;
      // Eigene Promise-Kette statt serviceRufen() (das feuert-und-vergisst) -
      // diese drei Aktionen brauchen einen sichtbaren Ladezustand, siehe
      // INTEGRATION_UPDATE_LAEUFT's Kommentar an der Deklaration.
      if (aktion === "pruefen" || aktion === "installieren") {
        // Im Großbild-Layout (Seitenleiste statt Tableiste) sprang die Seite
        // beim Klick sichtbar nach oben, obwohl render() den Scroll eigentlich
        // wiederherstellt (v.scrollTop = merkeScroll) - die Zustandsänderung
        // an sensor.audi_dashboard_app_version, die dieser Aufruf auslöst,
        // verschiebt scrollTop zusätzlich über einen zweiten, äußeren Pfad
        // (HA reicht jede Zustandsänderung an das eingebettete Panel durch).
        // Statt die Ursache dort zu jagen: fester Merkwert vor dem Klick, per
        // rAF nach jedem render() dieses Ablaufs erneut erzwungen - wirkt
        // gegen render()s eigene Wiederherstellung UND gegen den äußeren Pfad.
        const merkeScrollUpdate = $("#view").scrollTop;
        const scrollWiederherstellen = () => requestAnimationFrame(() => {
          const view = $("#view");
          if (view) view.scrollTop = merkeScrollUpdate;
        });
        INTEGRATION_UPDATE_LAEUFT = aktion; render();
        scrollWiederherstellen();
        HASS.callService(DOMAIN, `update_${aktion}`, {})
          .catch((err) => {
            hinweis(
              aktion === "pruefen" ? "Prüfung fehlgeschlagen" : "Installation fehlgeschlagen",
              err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden.",
            );
          })
          .finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); scrollWiederherstellen(); });
      } else if (aktion === "neustart") {
        INTEGRATION_UPDATE_LAEUFT = "neustart"; render();
        HASS.callService("homeassistant", "restart", {}).catch((err) => {
          // Bricht die Verbindung normalerweise durch den Neustart selbst ab,
          // bevor diese Zeile überhaupt liefe - nur ein echter Fehlschlag
          // (Dienst abgelehnt, o.ä.) kommt hier tatsächlich an.
          hinweis("Neustart fehlgeschlagen", err && err.message ? err.message : "Home Assistant konnte nicht neu gestartet werden.");
          INTEGRATION_UPDATE_LAEUFT = null; render();
        });
      }
      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, 2) : "", 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("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";
      // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem
      // naechsten Backend-Push wieder weg - jetzt wandert es mit.
      serviceRufen("fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art });
      render(); return;
    }
    // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü-
    // DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt).
    if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; }
    if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = SMAP_STIL === "satellit" ? "strasse" : "satellit"; standortTiles(); return; }
    if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; }
    if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; }
    if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; }
    if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; }
    if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; }
    // Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein
    // render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
    if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; }
    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;
    }
    // Gleicher Grund wie beim tankpreisfeld-Handler darunter: nur die
    // Anzeige patchen, kein render() - das würde das gerade bearbeitete Feld
    // neu erzeugen und den Fokus verlieren.
    const zeitfeldPraefix = e.target.dataset.fahrtzeitfeld;
    if (zeitfeldPraefix) {
      const anzeige = ROOT.getElementById(`${zeitfeldPraefix}DauerAnzeige`);
      if (anzeige) anzeige.textContent = fahrtDauerText(zeitfeldPraefix);
      return;
    }
    const praefix = e.target.dataset.tankpreisfeld;
    if (!praefix) return;
    const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", "."));
    const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", "."));
    const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`);
    if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 2)} €` : "Preis je Liter: wird berechnet";
  });

  ROOT.addEventListener("change", (e) => {
    if (e.target.dataset.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("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("backup_wiederherstellen", {
            profil_json: JSON.stringify(paket.profil),
            fahrten_json: JSON.stringify(TRIPS),
            tankvorgaenge_json: JSON.stringify(FILLS),
            // Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die
            // aktuelle Zuordnung stehen, statt geleert zu werden.
            ...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
          });
          bild = 0; render();
        } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
      };
      leser.readAsText(datei);
      return;
    }
    if (e.target.dataset.vcardImport !== undefined) {
      const datei = e.target.files[0]; if (!datei) return;
      const leser = new FileReader();
      leser.onload = () => {
        try {
          const felder = vcardParsen(leser.result);
          if (felder.name) CAR.service.autohaus.name = felder.name;
          if (felder.adresse) CAR.service.autohaus.adresse = felder.adresse;
          if (felder.tel) CAR.service.autohaus.tel = felder.tel;
          if (felder.mail) CAR.service.autohaus.mail = felder.mail;
          profilSpeichern(); render();
        } catch (err) { hinweis("vCard konnte nicht gelesen werden", err.message); }
      };
      leser.readAsText(datei);
      e.target.value = "";
      return;
    }
    if (e.target.dataset.bildupload !== undefined) {
      const datei = e.target.files[0]; if (!datei) return;
      const dateiname = e.target.dataset.bildupload;
      const leser = new FileReader();
      leser.onload = async () => {
        const daten_base64 = String(leser.result).split(",")[1] || "";
        try {
          await HASS.callService(DOMAIN, "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;
    }
    // Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
    // render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
    // Nutzer den gerade offenen Datumswähler weg.
    if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
    if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
    if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
    const vnotruf = e.target.dataset.vnotruf;
    if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
    const vvertrag = e.target.dataset.vvertrag;
    if (vvertrag) { CAR.versicherung[vvertrag] = e.target.value; profilSpeichern(); render(); return; }
    const vselbst = e.target.dataset.vselbst;
    if (vselbst) {
      const [i, feld] = vselbst.split("|");
      CAR.versicherung.selbst[+i][+feld] = e.target.value;
      profilSpeichern(); render(); return;
    }
    if (e.target.dataset.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, letzterVersionState = null;

/* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler
   weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie
   Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in
   frontend_veroeffentlichung.py. */
function belegErgebnisVerarbeiten(daten) {
  if (!daten) return;
  if (daten.fehler) {
    const kontext = daten.tank_id || belegZielPraefix;
    belegFehler = { context: kontext, meldung: daten.fehler };
    if (!daten.tank_id) belegEntwurf = null;
  } else if (daten.tank_id) {
    // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der
    // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über
    // tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene
    // Fehleranzeige für diesen Tankvorgang löschen.
    if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null;
  } else if (belegZielPraefix) {
    belegEntwurf = { praefix: belegZielPraefix, daten };
    belegFehler = null;
  }
  render();
}

/* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element
   frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch
   Minuten nach dem Start und nach SPA-interner Navigation weg und zurück
   beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand
   hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim
   allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem
   HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv
   auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen
   Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant,
   nicht uns.

   Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real
   zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die
   VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger
   dauern, bis die Integration überhaupt eingerichtet ist und ihre erste Veröffentlichung
   feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App
   blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch
   kein aktives Symbol, weil render() den harten Rücksprung bei
   !DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half,
   weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu
   dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit.
   Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen
   Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber
   endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt
   wird (disconnectedCallback stoppt den Timer, connectedCallback stößt ihn
   über datenLaden() wieder an).

   2026-08-17: Der Takt darf an keiner Stelle abreißen. Vorher stieg sowohl
   nachladeAnstossen() (bei fehlendem HASS/callWS) als auch datenLaden() (bei
   fehlendem HASS) still aus, ohne eine nächste Runde zu planen - danach half
   nur noch ein zufälliger hass-Push oder ein Klick ins Menü (go() ruft
   datenLaden() mit auf). Genau das war das gemeldete "Lädt … bis man auf
   einen Menüpunkt klickt". */
const NACHGELADEN = {};
let nachladeTimer = null;
let nachladeVersuche = 0;
const NACHLADE_INTERVALL_MS = 2000;
const NACHLADE_LANGSAM_MS = 10000;
const NACHLADE_SCHNELLE_VERSUCHE = 15;

function nachladeAnstossen() {
  if (nachladeTimer) return;
  nachladeVersuche++;
  // Erster Versuch fast sofort statt erst nach vollem Intervall - der
  // Wettlauf mit dem Start der Integration (siehe Kopfkommentar) ist meist
  // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach
  // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich
  // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer
  // im schnellen Takt weiterzufragen.
  const verzoegerung = nachladeVersuche === 1 ? 200
    : nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS
    : NACHLADE_LANGSAM_MS;
  nachladeTimer = setTimeout(async () => {
    nachladeTimer = null;
    // HASS kann hier noch fehlen: panel_custom erzeugt das Element und setzt
    // hass nicht immer in dieser Reihenfolge. Frueher brach die Schleife in so
    // einem Fall oben ganz ab (Bedingung !HASS || !HASS.callWS) und wurde nie
    // wieder angestossen - genau der dauerhaft haengende "Lädt …"-Bildschirm.
    // Jetzt wird die Runde nur uebersprungen, der Takt laeuft weiter.
    if (HASS && HASS.callWS) {
      try {
        const states = await HASS.callWS({ type: "get_states" });
        const finden = (id) => states.find((s) => s.entity_id === id);
        NACHGELADEN.profil = finden(E.profil) || NACHGELADEN.profil;
        NACHGELADEN.fahrten = finden(E.fahrten) || NACHGELADEN.fahrten;
        NACHGELADEN.tank = finden(E.tankvorgaenge) || NACHGELADEN.tank;
        NACHGELADEN.status = finden(E.fahrzeugstatus) || NACHGELADEN.status;
        NACHGELADEN.batt = finden(E.batterieverlauf) || NACHGELADEN.batt;
        // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
        // bis zufaellig ein Push kommt - siehe setupBereit().
        NACHGELADEN.entitaeten = finden(E.entitaeten) || NACHGELADEN.entitaeten;
      } catch (e) {
        // wird beim naechsten Versuch erneut probiert
      }
    }
    datenLaden(true);
    // Rueckversicherung: datenLaden() stoesst die naechste Runde selbst an,
    // aber nur, wenn es nicht vorher aussteigt. Der Takt darf an keiner
    // Stelle abreissen, solange noch keine Daten da sind.
    if (!DATEN_GELADEN) nachladeAnstossen();
  }, verzoegerung);
}

async function datenLaden(erzwingen) {
  if (!HASS) {
    if (!DATEN_GELADEN) nachladeAnstossen();
    return;
  }
  const profilState = HASS.states[E.profil] || NACHGELADEN.profil;
  const fahrtenState = HASS.states[E.fahrten] || NACHGELADEN.fahrten;
  const tankState = HASS.states[E.tankvorgaenge] || NACHGELADEN.tank;
  const statusState = HASS.states[E.fahrzeugstatus] || NACHGELADEN.status;
  const battState = HASS.states[E.batterieverlauf] || NACHGELADEN.batt;
  const belegState = HASS.states[E.belegErgebnis];
  // Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/
  // fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern,
  // nicht Teil des normalen Update-Rhythmus.
  const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten;
  const versionState = HASS.states[E.appVersion];
  if (versionState && versionState.state) APP_VERSION = versionState.state;
  if (versionState && versionState.attributes) {
    INTEGRATION_UPDATE = versionState.attributes.daten
      ? versionState.attributes.daten.integration_update ?? null
      : null;
  }
  if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;

  // sensor.audi_dashboard_app_version änderte sich (z. B. nach "Auf Update
  // prüfen"/"Update installieren" - siehe dienste.py's update_pruefen()/
  // update_installieren()) - ohne diesen Vergleich lief zwar die Zuweisung
  // von INTEGRATION_UPDATE oben bei jedem Tick mit, aber render() darunter
  // feuert nur, wenn der geaendert-Block das anstößt. Der prüfte bisher nur
  // profil/fahrten/tank/status/batt: das Ergebnis der Prüfung/Installation
  // stand zwar korrekt im Speicher, blieb aber unsichtbar, bis zufällig
  // eines der anderen fünf sich ändert - sichtbar als "braucht ein manuelles
  // Neuladen der Seite, um den Neustart-Knopf zu zeigen".
  const versionGeaendert = versionState !== letzterVersionState;
  letzterVersionState = versionState;

  if (belegState && belegState !== letzterBelegState) {
    letzterBelegState = belegState;
    belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten);
  }

  const geaendert = erzwingen
    || profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState
    || tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState;
  if (geaendert) {
    letzterProfilState = profilState; letzterFahrtenState = fahrtenState;
    letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState;

    const profil = profilState && profilState.attributes ? profilState.attributes.daten : null;
    if (profil) {
      const status = (statusState && statusState.attributes && statusState.attributes.daten) || {};
      LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated)
        ? new Date(statusState.last_updated).getTime() : null;
      PROFIL_ROH = profil;
      CONFIG = profilZuConfig(profil);
      CAR = profilZuCar(profil, status);
      TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || [];
      FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || [];
      BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || [];
      DATEN_GELADEN = true;
      nachladeVersuche = 0;

      render();
      return;
    }
  }

  // Nur die Version hat sich geändert (siehe versionGeaendert oben), sonst
  // nichts - der geaendert-Block darüber ist dafür nicht zuständig (der
  // bricht bei fehlendem profil sogar früher ab). Ein eigener, schlanker
  // Aufruf statt profilZuConfig()/profilZuCar() & Co. unnötig neu zu rechnen.
  if (versionGeaendert && DATEN_GELADEN) { render(); return; }

  if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart)
}

/* -------------------------------------------------------------- Element */
class AudiDashboardPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });

    /* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes
       Modul (dieses hier) mit customElements.define() fertig registriert
       ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für
       module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js):
       _createPanel() kann mehrfach anlaufen, und beim ersten, noch zu
       frühen document.createElement(tag) ist "audi-dashboard-panel" für
       den Browser noch ein unbekanntes Element. Die anschließende
       Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem
       Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft
       auf der Instanz an. Sobald customElements.define() greift und der
       Browser das Element "aufwertet" (führt genau diesen Konstruktor
       nach), holt die Plattform das NICHT automatisch nach - das gilt nur
       für über observedAttributes beobachtete HTML-Attribute, nicht für
       beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass()
       dauerhaft unerreichbar: _aufbauen() lief zwar (über
       connectedCallback(), einen echten Lifecycle-Hook), aber
       datenLaden() nie - sichtbares Symptom war ein dauerhaft
       hängendes "Lädt …" trotz vorhandener Backend-Daten. */
    for (const eigenschaft of ["hass", "narrow", "panel"]) {
      if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) {
        const wert = this[eigenschaft];
        delete this[eigenschaft];
        this[eigenschaft] = wert;
      }
    }
  }

  set hass(hass) {
    const erster = !HASS;
    HASS = hass;
    if (erster) {
      this._aufbauen();
    }
    // "ready" feuert bei jedem (Wieder-)Verbindungsaufbau des HA-Websockets,
    // auch nach einem über den "Jetzt neu starten"-Knopf ausgelösten
    // HA-Neustart: die Seite verliert dabei die Verbindung, INTEGRATION_UPDATE_
    // LAEUFT blieb aber auf "neustart" stehen - dessen einzige Rücksetzung
    // greift nur im Fehlerfall (siehe deren Klick-Handler), nicht beim
    // Erfolgsfall, weil dort niemand mehr zusieht, während HA neu startet.
    // Ohne dies blieb der "Home Assistant startet neu …"-Spinner nach einem
    // erfolgreichen Neustart stehen, bis der Nutzer die Seite von Hand neu lud.
    if (!VERBINDUNG_UEBERWACHT && HASS.connection && HASS.connection.addEventListener) {
      VERBINDUNG_UEBERWACHT = true;
      HASS.connection.addEventListener("ready", () => {
        if (INTEGRATION_UPDATE_LAEUFT === "neustart") {
          INTEGRATION_UPDATE_LAEUFT = null;
          render();
        }
      });
    }
    datenLaden(false);
  }
  get hass() { return HASS; }

  set narrow(v) { this._narrow = v; }
  set panel(v) { this._panel = v; }

  connectedCallback() {
    HOST = this;
    if (!this.shadowRoot.querySelector(".phone")) this._aufbauen();
    // Absicherung fuer den Fall, dass panel_custom das Element neu verbindet
    // (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut
    // auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand
    // haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt.
    if (!DATEN_GELADEN) datenLaden(false);
  }

  disconnectedCallback() {
    // Nachlade-Takt anhalten, solange das Panel nicht im Dokument haengt -
    // connectedCallback() stoesst ihn ueber datenLaden() wieder an.
    if (nachladeTimer) { clearTimeout(nachladeTimer); nachladeTimer = null; }
    // Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels
    // (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
    // window-Resize-Horcher und laufenden Kachelabrufen am Leben.
    if (MAP) { MAP.remove(); MAP = null; TILE = null; }
    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 = `${STATIK}/audi-dashboard.css?v=${CSS_VERSION}`;
    ROOT.appendChild(link);

    /* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab
       860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */
    const linkIos = document.createElement("link");
    linkIos.rel = "stylesheet";
    linkIos.href = `${STATIK}/audi-dashboard-ios.css?v=${CSS_VERSION}`;
    ROOT.appendChild(linkIos);

    const wrapper = document.createElement("div");
    wrapper.innerHTML = `
    <div class="phone">
      <div class="topbar">
        <button class="back" id="back" aria-label="Zurück"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="m10.21 12 5.12-5.08-.7-.71L8.79 12l5.84 5.8.7-.72L10.22 12Z"/></svg></button>        <button type="button" class="rings" id="marke" aria-label="Einstellungen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 289 100" fill="currentColor" aria-hidden="true"><defs><clipPath id="audiRingsClip" clipPathUnits="userSpaceOnUse"><path d="M0 100h289V0H0z"/></clipPath></defs><g clip-path="url(#audiRingsClip)" transform="matrix(1 0 0 -1 0 100.00040017)"><path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50"/></g></svg></button>
        <div class="head"><div class="eyebrow" id="eyebrow"></div><div class="title" id="title">Übersicht</div>
          <div class="sync" id="sync"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m18.65 4.95 2.56 2.55-2.56 2.55-.7-.7L19.29 8H7.2A5.24 5.24 0 0 0 2 13.38V14H1v-.6A6.24 6.24 0 0 1 7.2 7h12.1l-1.34-1.35.7-.7ZM23 11.59A6.24 6.24 0 0 1 16.8 18H4.7l1.35 1.35-.7.7-2.56-2.55 2.56-2.55.7.7L4.71 17H16.8a5.24 5.24 0 0 0 5.2-5.38V11h1v.6Z"/></svg><span></span></div></div>
        <button class="themebtn" id="theme" hidden aria-hidden="true"><svg id="themeicon" viewBox="0 0 20 20" width="15" height="15"></svg></button>
        <button class="profilbtn" id="ringsbtn" aria-label="Einstellungen"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .33 1.76l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.6 1.6 0 0 0-1.76-.33 1.6 1.6 0 0 0-1 1.47V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.47 1.6 1.6 0 0 0-1.77.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.6 1.6 0 0 0 4.6 15a1.6 1.6 0 0 0-1.47-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.47-1 1.6 1.6 0 0 0-.33-1.77l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.6 1.6 0 0 0 9 4.6 1.6 1.6 0 0 0 10 3.13V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.47 1.6 1.6 0 0 0 1.77-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.6 1.6 0 0 0 19.4 9v.04a1.6 1.6 0 0 0 1.47 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1Z"/></svg></button>
      </div>
      <div class="ptr" id="ptr"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M20.06 8.36c-1.21-3.33-4.3-6.46-8.5-6.46-3.69 0-6.97 2-8.55 5.28a9.47 9.47 0 0 0 4.23 12.77A9.56 9.56 0 0 0 20 15.72l-.89-.44a8.56 8.56 0 0 1-11.43 3.77A8.47 8.47 0 0 1 3.9 7.62a8.38 8.38 0 0 1 7.65-4.72c3.8 0 6.64 2.94 7.66 6.1h-5.66v1h7.5V2.5h-1v5.86Z"/></svg><span>Ziehen zum Aktualisieren</span></div>
      <main id="view"><div class="tile" style="margin-top:20px;display:flex;align-items:center;gap:10px"><span class="lade-spinner" aria-hidden="true"></span><span class="label" style="margin:0">Lädt …</span></div></main>
      <div id="overlay"></div>
      <nav class="tabbar" id="tabbar"></nav>
    </div>`;
    ROOT.appendChild(wrapper);

    ereignisseVerdrahten();
    randwischenVerdrahten();
    standortMenuVerdrahten();
    themeIcon();
    setInterval(standAlterTicken, 1000);

    // Frisch aufgebautes Markup zeigt bis hierhin nur das statische "Lädt …"-
    // Grundgerüst aus dem Template oben - dasselbe bekannte Rennen wie in
    // go()'s Kopfkommentar (siehe dort/nachladeAnstossen): panel_custom baut
    // dieses Element bei einem echten HA-Neustart neu auf (ein neues
    // customElements-Objekt, nicht dasselbe wiederverwendet), aber die
    // modul-globalen Daten (CONFIG/CAR/...) und DATEN_GELADEN überleben das
    // unverändert, weil das JS-Modul selbst nicht neu geladen wird. Ohne
    // diese Zeile blieb genau dieses frische Grundgerüst für immer stehen,
    // bis der Nutzer zufällig einen Tab anklickte (go()'s eigener,
    // unbedingter render()-Aufruf) oder die Seite von Hand neu lud - obwohl
    // in CONFIG/CAR längst brauchbare (ggf. kurz veraltete, durch den
    // nächsten Datenabgleich ohnehin gleich aufgefrischte) Daten lagen.
    if (DATEN_GELADEN) render();
  }
}

if (!customElements.get("audi-dashboard-panel")) {
  customElements.define("audi-dashboard-panel", AudiDashboardPanel);
}

