e1ebe6d694
Systematischer Bildvergleich der companion-app gegen das HA-Panel (companion-app/AGENTS.md Kapitel 1-11): Farbtoken/Radien aus dem falschen Stylesheet gelesen (iOS-Overlay statt Basis-CSS, betraf fast jede Kachel/Farbe/Radius app-weit), vier app-weite @audi-dash/ui-Bugs (Switch/Seg/Feld rot statt neutral bzw. falsche Feldbreite), Reifen-Seite strukturell neu gebaut (beide Radsätze gleichzeitig statt Umschalter, editierbare Felder, Anzugsmoment-/km-Korrektur), Standort-Feature komplett neu (fehlte bisher ganz), sowie diverse Struktur-/Typografie-/Datenlücken in MeinAudi/Service/Versicherung/Sicherheit/Fahrten/Tanken/Statistik/Batterie/Einstellungen. Manifest auf 2026.8.30.2 angehoben, OTA-Bündel neu gebaut und in audi_ha_test verifiziert (sauberer Neustart, keine Tracebacks). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5777 lines
334 KiB
JavaScript
5777 lines
334 KiB
JavaScript
/* ================================================================
|
||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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,
|
||
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,
|
||
// Default true (auch für Profile von vor dieser Funktion, die das Feld
|
||
// noch nicht kennen) - deckt sich mit identitaet.py's eigenem
|
||
// fahrzeug.get("fin_automatisch", True).
|
||
finAutomatisch: p.fahrzeug.fin_automatisch !== false,
|
||
details: p.fahrzeug.details,
|
||
erstzulassung: p.fahrzeug.erstzulassung,
|
||
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"),
|
||
// Neueste zuerst, schon so im Profil (reifen.py archivieren()). Nur
|
||
// gelesen - bearbeitet/gelöscht wird über eigene Dienste
|
||
// (reifen_archiv_aktualisieren/-loeschen), nicht über profilSpeichern,
|
||
// aus demselben Grund wie km oben: kein Stand im Browser darf einen
|
||
// zwischenzeitlichen Servereintrag überschreiben.
|
||
archiv: p.reifen.archiv || [],
|
||
},
|
||
service: p.service,
|
||
technik: p.technik,
|
||
ausstattung: p.ausstattung,
|
||
};
|
||
}
|
||
|
||
/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt
|
||
mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */
|
||
function configCarZuProfil(vorherigesProfil) {
|
||
const p = JSON.parse(JSON.stringify(vorherigesProfil));
|
||
p.fahrzeug.titel = CONFIG.fahrzeugtitel;
|
||
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
|
||
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
|
||
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
|
||
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
|
||
p.fahrzeug.fin = CAR.fin;
|
||
p.fahrzeug.fin_automatisch = CAR.finAutomatisch;
|
||
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
||
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
||
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
||
p.einstellungen.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); }
|
||
/* Datum + Uhrzeit in einer Zeile, für Start-/Zielzeitpunkt der Einzelfahrt. */
|
||
const dezeitpunkt = (d) => (gueltigesDatum(d)
|
||
? `${dedat(d)} · ${d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
|
||
: "–");
|
||
|
||
const MISCHDAUER = 180;
|
||
function tagesleistung() {
|
||
// §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem
|
||
// Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die
|
||
// Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000.
|
||
if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 };
|
||
const b = CAR.service.buch;
|
||
if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 };
|
||
const alt = b[b.length - 1];
|
||
const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag);
|
||
const langfristig = (CAR.odo - alt.km) / tageGesamt;
|
||
const letzter = b[0];
|
||
const tageSeit = (heute() - dat(letzter.datum)) / dTag;
|
||
const kmSeit = CAR.odo - letzter.km;
|
||
if (tageSeit < 14 || kmSeit <= 0)
|
||
return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km };
|
||
const aktuell = kmSeit / tageSeit;
|
||
const g = Math.min(1, tageSeit / MISCHDAUER);
|
||
return {
|
||
wert: langfristig * (1 - g) + aktuell * g,
|
||
quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt",
|
||
tage: Math.round(tageSeit), km: kmSeit,
|
||
};
|
||
}
|
||
function kmProTag() { return tagesleistung().wert; }
|
||
function prognose(zielKm) {
|
||
if (!CAR.odoBekannt) return { tage: null, datum: null };
|
||
const rest = zielKm - CAR.odo;
|
||
if (rest <= 0) return { tage: 0, datum: heute() };
|
||
const tage = Math.round(rest / kmProTag());
|
||
return { tage, datum: new Date(heute().getTime() + tage * dTag) };
|
||
}
|
||
function ics(titel, datum, ort, notiz) {
|
||
const tag = datum.replace(/-/g, "");
|
||
const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, "");
|
||
const text = [
|
||
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT",
|
||
"UID:" + Date.now() + "@audi-dashboard.local",
|
||
"DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z",
|
||
"DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster,
|
||
"SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""),
|
||
"BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM",
|
||
"END:VEVENT", "END:VCALENDAR",
|
||
].join("\r\n");
|
||
const a = document.createElement("a");
|
||
a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text);
|
||
a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics";
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
}
|
||
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.
|
||
CARTOs anonyme basemaps.cartocdn.com-Kacheln (vorher hier) verlangen
|
||
inzwischen einen eigenen API-Schlüssel - ohne ihn zeigten die Kacheln nur
|
||
noch "API key required". Die Standard-OpenStreetMap-Kacheln sind weiterhin
|
||
ohne Schlüssel nutzbar und decken diesen Bedarf (ein Fahrzeug, geringes
|
||
Kartenaufkommen) ohne eigenen Dienst. */
|
||
const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||
const ATTR = "© OpenStreetMap contributors";
|
||
/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die
|
||
CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */
|
||
const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
|
||
const SAT_ATTR = "Tiles © Esri";
|
||
|
||
const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
|
||
const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";
|
||
|
||
/* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head:
|
||
Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head,
|
||
fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen
|
||
dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px
|
||
hoch), von der Karte war nur ein schmaler Streifen zu sehen und der
|
||
Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS
|
||
gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */
|
||
function leafletCssEinhaengen() {
|
||
if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve();
|
||
return new Promise((fertig) => {
|
||
const css = document.createElement("link");
|
||
css.rel = "stylesheet";
|
||
css.href = LEAFLET_CSS_URL;
|
||
css.dataset.leafletCss = "1";
|
||
/* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht
|
||
die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */
|
||
css.onload = fertig;
|
||
css.onerror = fertig;
|
||
ROOT.appendChild(css);
|
||
});
|
||
}
|
||
|
||
function leafletLaden() {
|
||
/* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer
|
||
Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */
|
||
const css = leafletCssEinhaengen();
|
||
if (window.L) return css;
|
||
if (!LEAFLET_LADEND) {
|
||
LEAFLET_LADEND = new Promise((resolve, reject) => {
|
||
const script = document.createElement("script");
|
||
script.src = LEAFLET_JS_URL;
|
||
script.onload = resolve;
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
return Promise.all([css, LEAFLET_LADEND]);
|
||
}
|
||
|
||
function mapBox() { return `<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 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) {
|
||
// Echte Streckenlinie aus dem GPS-Verlauf (route, screening.py), wenn
|
||
// vorhanden - sonst eine ehrliche gerade Linie zwischen Start und Ziel,
|
||
// keine erfundene Kurve mehr (siehe companion-app/Karte.tsx, dieselbe
|
||
// Haltung: keine Route zu zeigen, die es so nicht gab).
|
||
const track = Array.isArray(t.route) && t.route.length >= 2
|
||
? t.route
|
||
: [[t.start_lat, t.start_lon], [t.end_lat, t.end_lon]];
|
||
const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9, dashArray: Array.isArray(t.route) && t.route.length >= 2 ? null : "4 6" }).addTo(MAP);
|
||
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
|
||
}
|
||
} 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"/>',
|
||
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"/>',
|
||
/* list-l/-s, polls-l/-s, package-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer
|
||
als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie
|
||
poi-l/poi-s. list: Sprung vom Diagramm zur Werteliste (Batteriespannung).
|
||
polls: Statistik-Tab in der Tableiste. package: "Neue Räder anlegen". */
|
||
listL: '<path fill="currentColor" d="M14 19h-2v-1h2zm2-1h18v1H16zm-4 6h2v1h-2zm4 0h12v1H16zm-4 6h2v1h-2zm4 0h5v1h-5z"/>',
|
||
listS: '<path fill="currentColor" d="M5 8H3V7h2zm2-1h14v1H7zm-4 4h2v1H3zm4 0h9v1H7zm-4 4h2v1H3zm4 0h5v1H7z"/>',
|
||
pollsL: '<path fill="currentColor" d="M19 3h10v37H19zm1 1v35h8V4zm13 8h10v28H33zm1 1v26h8V13zM5 19h10v21H5zm1 1v19h8V20zM5 44h38v1H5z"/>',
|
||
pollsS: '<path fill="currentColor" d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z"/>',
|
||
packageL: '<path fill="currentColor" d="m24 2.97 20.99 9v24.07l-20.99 9-20.99-9V11.96l20.99-9ZM16.11 7.4l19.18 8.29 7.97-3.41L24 4.03l-7.89 3.38Zm27.9 5.62L24.5 21.39v22.37L44 35.39V13.03Zm-20.5 30.73V21.39L4 13.03v22.36l19.52 8.37ZM4.74 12.29 24 20.54l10.06-4.31-19.19-8.29L4.74 12.3Z"/>',
|
||
packageS: '<path fill="currentColor" d="m12 .96 11 4.71v12.66l-11 4.71-11-4.71V5.67zM2 6.76v10.91l9.5 4.07V10.83zm10.5 4.07v10.91l9.5-4.07V6.76zM21.23 6 12 2.04 8.41 3.58l9.2 3.97zM7.15 4.12 2.77 6 12 9.96l4.34-1.86-9.2-3.98Z"/>',
|
||
/* 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">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS.fuel}</svg>
|
||
<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 teaser"><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 Wartungsplan fortgeschrieben"));
|
||
return { haupt, neben, restKm };
|
||
}
|
||
|
||
/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus-
|
||
Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet
|
||
dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24,
|
||
dann Tage. */
|
||
function 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 Wartungsplan</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</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>montiert: ${r.aktiv}räder</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" : "–"}</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. */
|
||
/* bvPunkte enthält nur Ruhespannungs-Messwerte (<= AGM_RUHE_MAX_V) - sowohl
|
||
Grundlage für Ladezustand/SOH-Trend als auch für das Diagramm selbst.
|
||
Generatorspannungen (> AGM_RUHE_MAX_V, z. B. 13,01 V) tauchen dort bewusst
|
||
nicht auf (Nutzerwunsch: "13,01 shall be generator und not part of the
|
||
diagram") - anders als in einer Zwischenfassung, die kurzzeitig jede
|
||
Messung zeichnete; die Messwertliste (vBatterieliste()) zeigt weiterhin
|
||
ausnahmslos alle Tage samt "Generatorspannung"-Kennzeichnung, sie liest
|
||
direkt aus BATTERIEVERLAUF, nicht aus bvPunkte. */
|
||
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null;
|
||
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
|
||
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
|
||
let bvTapKandidat = null, bvBewegtSeit = false;
|
||
/* BV_W ist nur der Startwert: bvZeichnen() setzt die viewBox auf die
|
||
tatsächlich gerenderte Pixelbreite (bvBreite), damit eine Einheit im
|
||
Koordinatensystem genau einem Bildschirmpixel entspricht. Vorher stand hier
|
||
eine feste 320er-viewBox mit preserveAspectRatio="none" - auf der 736px
|
||
breiten Desktop-Spalte wurde dadurch alles um Faktor 2,3 in die Breite
|
||
gezogen, sichtbar an den unnatürlich breit gelaufenen Achsenzahlen und an
|
||
überlappenden Beschriftungen. BV_MB ist klein, seit die Datumsbeschriftung
|
||
unter dem Diagramm entfällt (Datum steht beim Antippen im Tooltip). */
|
||
const BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 12;
|
||
const BV_W = 320;
|
||
let bvBreite = BV_W;
|
||
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||
|
||
/* Referenzwerte für AGM-Starterbatterien (12V). Zwei unterschiedliche Zahlen,
|
||
nicht eine: AGM_RUHE_MAX_V ist die Klassifizierungsgrenze "gilt diese
|
||
Messung überhaupt noch als Batterie(ruhe)spannung, nicht schon
|
||
Generatorspannung" (Diagramm/SOH-Trend) - vom Nutzer direkt auf 13,0 V
|
||
festgelegt ("max voltage for AGM Battery is 13V, everything above is the
|
||
generator"). BV_SOC_TABELLE (unten) ist davon unabhängig die
|
||
Ladezustands-Kurve selbst - deren "100 % / voll"-Eckwert bleibt bei den
|
||
ursprünglich recherchierten 12,8 V (aboutdarwin.com/agm-battery-voltage-
|
||
charts, brsbattery.com: volle Ruhespannung), vom Nutzer ausdrücklich
|
||
bestätigt ("12,8v voll was already right"). Eine Messung zwischen 12,8 und
|
||
13,0 V zählt also als gültige Batteriemessung (Diagrammpunkt, fließt in
|
||
SOH ein), zeigt aber weiterhin 100 % SOC - dieselbe Klemmung, die die
|
||
Tabelle für jeden Wert >= ihrem letzten Eckwert ohnehin schon anwendet. Der
|
||
Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - deutlich über
|
||
beiden Grenzen, die Trennung zur Generatorspannung bleibt also eindeutig.
|
||
|
||
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
|
||
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
|
||
Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet,
|
||
und zwar auf den Tagesminimalwert: der fällt typischerweise auf den
|
||
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
|
||
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
|
||
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
|
||
tauchen entsprechend nicht in der Ladezustands-Statistik auf. */
|
||
const AGM_RUHE_MAX_V = 13.0;
|
||
const BV_SOC_TABELLE = [
|
||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||
];
|
||
function bvSocProzent(v) {
|
||
const t = BV_SOC_TABELLE;
|
||
if (v <= t[0][0]) return 0;
|
||
if (v >= t[t.length - 1][0]) return 100;
|
||
for (let i = 0; i < t.length - 1; i++) {
|
||
const [v0, s0] = t[i], [v1, s1] = t[i + 1];
|
||
if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0));
|
||
}
|
||
return 100;
|
||
}
|
||
function bvSocLabel(soc) {
|
||
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch";
|
||
}
|
||
// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei
|
||
// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) -
|
||
// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok),
|
||
// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden.
|
||
function bvHexZuRgb(hex) {
|
||
const h = hex.trim().replace("#", "");
|
||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||
}
|
||
function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); }
|
||
function bvFarbstufen() {
|
||
const stil = getComputedStyle(HOST);
|
||
return {
|
||
bad: bvHexZuRgb(stil.getPropertyValue("--bad")),
|
||
warn: bvHexZuRgb(stil.getPropertyValue("--warn")),
|
||
ok: bvHexZuRgb(stil.getPropertyValue("--ok")),
|
||
};
|
||
}
|
||
function bvFarbe(v, stufen) {
|
||
let rgb;
|
||
if (v <= 11.6) rgb = stufen.bad;
|
||
else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6));
|
||
else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2));
|
||
else rgb = stufen.ok;
|
||
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
}
|
||
|
||
/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine
|
||
Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe
|
||
Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of-
|
||
health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt,
|
||
nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase
|
||
der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen
|
||
eingeordnet (übliche Lebensdauer 4-6 Jahre). */
|
||
function bvSohBewertung() {
|
||
const TAG_MS = 86400000;
|
||
if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." };
|
||
const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS;
|
||
if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." };
|
||
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS;
|
||
const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs);
|
||
const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs);
|
||
const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length;
|
||
const delta = neuD - altD;
|
||
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad";
|
||
const text = delta >= -0.05
|
||
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
|
||
: delta >= -0.15
|
||
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
|
||
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten.";
|
||
return { status, text, delta };
|
||
}
|
||
|
||
function vBatterieverlauf() {
|
||
// Ein Punkt je Tag mit einer echten Ruhespannungsmessung (<= AGM_RUHE_MAX_V,
|
||
// z. B. 13,0 V), gezeichnet wird der Tagesminimalwert - min_ts ist der
|
||
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
|
||
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. Der
|
||
// Tageshöchstwert bekommt bewusst KEINEN eigenen Punkt, er steht beim
|
||
// Antippen im Tooltip. Tage, deren Minimum über der Grenze liegt (nie
|
||
// wirklich in Ruhe beobachtet, sondern nur Generatorspannung), tauchen im
|
||
// Diagramm nicht auf (Nutzerwunsch) - die Messwertliste zeigt sie weiterhin
|
||
// alle, mit "Generatorspannung"-Kennzeichnung.
|
||
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min_ts && e.min <= AGM_RUHE_MAX_V)
|
||
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum, temp: e.min_temp_c }))
|
||
.sort((a, b) => a.t - b.t);
|
||
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
|
||
|
||
const genug = bvPunkte.length >= 2;
|
||
bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null;
|
||
bvDomain = genug ? [...bvVollDomain] : null;
|
||
// Y-Achse fest auf 10-15V (Nutzerwunsch) statt aus den Punkten berechnet -
|
||
// deckt den relevanten Bereich ab (12V-Bleibatterie, Ladespannung des
|
||
// Alternators mit Reserve), ohne die Plausibilitätsgrenze SPANNUNG_MIN_V=8V
|
||
// (batterie.py) selbst als Achsenskala zu übernehmen. Bleibt dadurch auch
|
||
// bei jedem Zoom/Pan unverändert, nicht nur "einmalig aus allen Punkten"
|
||
// wie zuvor.
|
||
bvYDomain = genug ? [10, 15] : null;
|
||
|
||
const stufen = bvFarbstufen();
|
||
// bvPunkte enthält seit dem Filter oben ohnehin nur noch Ruhespannungen
|
||
// (<= AGM_RUHE_MAX_V) - kein separates bvRuhePunkte mehr nötig.
|
||
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
|
||
const soc = letzter ? bvSocProzent(letzter.v) : null;
|
||
const soh = bvSohBewertung();
|
||
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
|
||
const leerText = BATTERIEVERLAUF.length === 0
|
||
? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist."
|
||
: "Noch zu wenige Messwerte für einen Verlauf.";
|
||
|
||
return `
|
||
<div class="tile">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span class="label">Verlauf</span>
|
||
<button class="iconbtn" aria-label="Messwertliste" data-go="battliste:x">${ciSVG(CI.listS, 18)}</button>
|
||
</div>
|
||
${genug ? `
|
||
<div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
|
||
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" style="width:100%;height:${BV_H}px;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>`;
|
||
}
|
||
|
||
/* Messwertliste zur Batteriespannungs-Kachel: ein Eintrag pro Tag (dieselben
|
||
Daten wie das Diagramm), aber als Liste mit Datum, Uhrzeit, Spannung und
|
||
Außentemperatur. Jede Zeile wischbar zum Löschen wie Fahrten/Tankvorgänge
|
||
(art "batt" in eintragLoeschen()/LOESCH_TEXT/LOESCH_HINWEIS) - kein
|
||
data-go/Chevron, es gibt keine Detailseite für einen einzelnen Messwert.
|
||
Liegt der Tagesminimalwert über AGM_RUHE_MAX_V, wurde er nie in Ruhe
|
||
gemessen (an dem Tag stand das Fahrzeug nie lange genug still) - die Zeile
|
||
markiert das mit "Generatorspannung" statt so zu tun, als sei es ein
|
||
Ruhewert (dieselbe Schwelle wie im Diagramm selbst, siehe bvPunkte oben). */
|
||
function vBatterieliste() {
|
||
const messwerte = BATTERIEVERLAUF.filter((e) => e.min != null).slice().reverse();
|
||
if (messwerte.length === 0) {
|
||
return `<div class="tile"><span class="label" style="color:var(--fg3)">Keine Messwerte</span></div>`;
|
||
}
|
||
return `<div class="tile">${messwerte.map((e) => {
|
||
const zeit = e.min_ts ? new Date(e.min_ts) : null;
|
||
const wann = zeit ? `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : dedat(new Date(e.datum));
|
||
return swipeZeile(`batt:${e.datum}`, `
|
||
<div class="leaf">
|
||
<span class="k">${de(e.min, 2)} V<small>${e.min_temp_c != null ? de(e.min_temp_c, 1) + " °C" : "—"}</small></span>
|
||
<span class="v">${wann}${e.min > AGM_RUHE_MAX_V ? `<span class="pill" style="margin-top:4px;padding:3px 9px;font-size:11px">Generatorspannung</span>` : ""}</span>
|
||
</div>`);
|
||
}).join("")}</div>`;
|
||
}
|
||
|
||
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (bvBreite - BV_ML - BV_MR); }
|
||
/* Auf [0,1] geklemmter Anteil statt roher Extrapolation - ein Punkt außerhalb
|
||
von yMin/yMax (z. B. ein Ausreißer, der die Plausibilitätsgrenze auf einer
|
||
noch nicht aktualisierten Instanz umgangen hat) zeichnet sich sonst weit
|
||
außerhalb der sichtbaren Fläche und reißt die Linie unkontrolliert nach
|
||
unten/oben aus - geklemmt liegt er sichtbar am oberen/unteren Rand. */
|
||
function bvSkalaY(v, yMin, yMax) {
|
||
const anteil = Math.max(0, Math.min(1, (v - yMin) / (yMax - yMin)));
|
||
return BV_H - BV_MB - anteil * (BV_H - BV_MT - BV_MB);
|
||
}
|
||
function bvDomainKlemmen(domain) {
|
||
let [a, b] = domain;
|
||
const volle = bvVollDomain[1] - bvVollDomain[0];
|
||
let breite = Math.min(b - a, volle);
|
||
breite = Math.max(breite, volle * 0.02);
|
||
if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; }
|
||
if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; }
|
||
return [a, b];
|
||
}
|
||
/* Eine Koordinateneinheit ist seit der dynamischen viewBox genau ein Pixel
|
||
(siehe bvBreite), die Ränder gelten also unskaliert. */
|
||
function bvPlotRahmen(rect) {
|
||
return { linksPx: rect.left + BV_ML, breitePx: Math.max(1, rect.width - BV_ML - BV_MR) };
|
||
}
|
||
|
||
function bvZeichnen() {
|
||
const svg = $("#bvSvg");
|
||
if (!svg || !bvDomain || !bvYDomain) return;
|
||
// Feste Y-Achse (siehe vBatterieverlauf) statt Neuberechnung aus dem
|
||
// gerade sichtbaren Zoom-Ausschnitt, damit die Achse beim Zoomen/Verschieben
|
||
// nicht mehr "streckt".
|
||
const [yMin, yMax] = bvYDomain;
|
||
|
||
/* Koordinatensystem an die echte Rendergröße koppeln: eine Einheit = ein
|
||
Pixel. Ohne das zog die feste 320er-viewBox mit preserveAspectRatio="none"
|
||
auf breiten Spalten alles in die Breite, Achsenzahlen inklusive.
|
||
Ein ResizeObserver hält das beim Fensterwechsel nach (die viewBox selbst
|
||
ändert die Elementgröße nicht, deshalb keine Endlosschleife). */
|
||
const gemessen = svg.getBoundingClientRect().width;
|
||
if (gemessen > 0) bvBreite = gemessen;
|
||
svg.setAttribute("viewBox", `0 0 ${bvBreite.toFixed(1)} ${BV_H}`);
|
||
if (!svg._bvBeobachtet && typeof ResizeObserver !== "undefined") {
|
||
svg._bvBeobachtet = true;
|
||
new ResizeObserver(() => bvZeichnen()).observe(svg);
|
||
}
|
||
|
||
// etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am
|
||
// Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt
|
||
// weiterläuft
|
||
const spanne = bvDomain[1] - bvDomain[0];
|
||
const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05);
|
||
const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" ");
|
||
|
||
const gitter = [0, 1, 2, 3].map((i) => {
|
||
const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax);
|
||
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${(bvBreite - BV_MR).toFixed(1)}" 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("");
|
||
/* Keine Datumsbeschriftung unter dem Diagramm (Nutzerwunsch): der Zeitraum
|
||
steht als Text unter der Zeichenfläche (#bvRange), das Datum einer
|
||
einzelnen Messung erscheint beim Antippen im Tooltip. */
|
||
const stufen = bvFarbstufen();
|
||
const kreise = rahmen.map((p) => {
|
||
const idx = bvPunkte.indexOf(p);
|
||
const gewaehlt = idx === bvAusgewaehlt;
|
||
return `<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("");
|
||
/* Sanfte Flaechenfuellung unter der Linie (wie iOS Health/Aktie-Charts) -
|
||
macht aus der duennen Linie auf der jetzt vollen Spaltenbreite ein
|
||
"fertiges" Diagramm statt eines leeren Bandes. Rein dekorativ, keine
|
||
neue Information; niedrige Deckkraft, damit das Gitter durchscheint. */
|
||
const flaeche = rahmen.length >= 2
|
||
? `<path d="M${bvSkalaX(rahmen[0].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} L${linie.split(" ").join(" L")} L${bvSkalaX(rahmen[rahmen.length - 1].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} Z"
|
||
fill="url(#bvGrad)" stroke="none"/>`
|
||
: "";
|
||
|
||
svg.innerHTML = `<defs><linearGradient id="bvGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0" stop-color="var(--fg)" stop-opacity="0.16"/>
|
||
<stop offset="1" stop-color="var(--fg)" stop-opacity="0"/>
|
||
</linearGradient></defs>${gitter}${flaeche}<polyline points="${linie}" fill="none" stroke="var(--fg)"
|
||
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>${kreise}`;
|
||
|
||
const rangeEl = $("#bvRange");
|
||
if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`;
|
||
const tip = $("#bvTooltip");
|
||
if (tip) {
|
||
if (bvAusgewaehlt != null) {
|
||
const p = bvPunkte[bvAusgewaehlt];
|
||
const zeit = new Date(p.minTs);
|
||
tip.style.display = "block";
|
||
/* Der Höchstwert des Tages hat bewusst keinen eigenen Punkt im Diagramm
|
||
(Nutzerwunsch) - hier ist die Stelle, an der er sichtbar wird. */
|
||
const teile = [
|
||
`${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`,
|
||
`Min ${de(p.vMin, 2)} V`,
|
||
];
|
||
if (p.vMax != null) teile.push(`Max ${de(p.vMax, 2)} V`);
|
||
if (p.temp != null) teile.push(`${de(p.temp, 1)} °C`);
|
||
tip.textContent = teile.join(" · ");
|
||
} else {
|
||
tip.style.display = "none";
|
||
}
|
||
}
|
||
}
|
||
|
||
function bvNaechsterPunkt(clientX) {
|
||
const svg = $("#bvSvg"); if (!svg) return null;
|
||
const rect = svg.getBoundingClientRect();
|
||
const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]);
|
||
let beste = null, besterAbstand = Infinity;
|
||
bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } });
|
||
return beste;
|
||
}
|
||
|
||
let svAuswahl = 0;
|
||
function vService() {
|
||
const sv = CAR.service;
|
||
return `
|
||
<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 Wartungsplan</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">Wartungsplan</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 Wartungsplan 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>
|
||
<span style="display:flex;align-items:center;gap:10px;min-width:0">
|
||
<span style="display:flex;align-items:center;gap:6px;flex:0 0 auto">
|
||
<span class="label" style="margin:0;text-transform:none;letter-spacing:0;font-size:12.5px;color:var(--fg2)">automatisch</span>
|
||
<label class="switch"><input type="checkbox" data-fin-automatisch ${CAR.finAutomatisch ? "checked" : ""}><span class="track"></span></label>
|
||
</span>
|
||
<input id="einFin" value="${esc(CAR.fin || "")}" data-einrichten="fin" ${CAR.finAutomatisch ? "disabled" : ""}
|
||
title="${CAR.finAutomatisch ? "Wird automatisch aus den im Setup zugeordneten Sensoren ermittelt - \"automatisch\" ausschalten, um von Hand einzutragen." : ""}">
|
||
</span></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. Fahrten, Tankvorgänge und der
|
||
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine
|
||
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
|
||
unangetastet.</span>
|
||
<button class="aktion" data-datensatz="sichern">Datensatz sichern</button>
|
||
<button class="aktion" data-datensatz="laden">Datensatz laden</button>
|
||
</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">Version</span>
|
||
<dl class="rows">
|
||
<div class="row" style="border-bottom:0"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</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) {
|
||
if (!feld.units.includes(e.unit)) return false;
|
||
} else if (feld.device_classes && (feld.device_classes.includes("date") || feld.device_classes.includes("timestamp"))) {
|
||
// Kein Einheiten-Feld, aber "Datum" ist der angezeigte Output
|
||
// (feldEinheitAnzeige()) - dieselbe Bedingung hier gespiegelt, damit
|
||
// "Nur passende Sensoren anzeigen" wirklich nur Sensoren mit
|
||
// passendem Output zeigt, nicht jeden beliebigen sensor.*.
|
||
if (e.deviceClass !== "date" && e.deviceClass !== "timestamp") return false;
|
||
}
|
||
}
|
||
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
|
||
return true;
|
||
});
|
||
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>` : ""}
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:var(--sp-4)">
|
||
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
|
||
<button class="iconbtn archivbtn" data-reifenarchivieren="${k}" aria-label="Neue Räder anlegen">${ciSVG(CI.packageS, 18)}</button>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
${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>
|
||
<div class="feld feld--breit"><label>Kommentar</label>
|
||
<input value="${esc(t.kommentar ?? "")}" data-rf="${k}|kommentar" placeholder="noch offen"></div>
|
||
</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")}
|
||
${vReifenArchiv()}`;
|
||
}
|
||
|
||
/* Übersicht der per "Neue Räder anlegen" archivierten Sätze - jede Zeile
|
||
aufklappbar (dasselbe acc-h/acc-b-Muster wie die Tankvorgänge-Liste), mit
|
||
inline editierbaren Feldern wie bei den laufenden Sätzen oben, nur über
|
||
eigene Dienste statt profilSpeichern (siehe profilZuCar()). */
|
||
function vReifenArchiv() {
|
||
const archiv = CAR.reifen.archiv || [];
|
||
if (archiv.length === 0) return "";
|
||
// Kein .mitEinheit hier (das erzwingt eine feste 96px-Eingabebreite fürs
|
||
// Zahl+Einheit-Muster, z. B. km/Nm - für freien Text wie Hersteller/Modell/
|
||
// Kommentar riss das den Wert ab, siehe live beobachtet: "Continenta",
|
||
// "SportConta"). Genau wie feld() in satz() oben: einfaches <input>, das
|
||
// sich über den .feld-Flex den ganzen Rest der Zeile nimmt.
|
||
const feld = (a, schluessel, bez) => `<div class="feld"><label>${bez}</label>
|
||
<input value="${esc(a[schluessel] ?? "")}" data-rfa="${a.id}|${schluessel}" placeholder="–"></div>`;
|
||
return `<div class="tile"><span class="label">Archiv</span>
|
||
${archiv.map((a, i) => {
|
||
const satzLabel = a.satz === "winter" ? "Winterräder" : "Sommerräder";
|
||
return `<button class="acc-h" aria-expanded="false" data-acc="rfarch${i}"><span class="mark"></span>
|
||
<span class="k">${satzLabel}</span>
|
||
<span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
|
||
<span style="color:var(--fg);font-size:15px">${a.km != null ? de(a.km) + " km" : "–"}</span>
|
||
<small>Ersetzt am ${dedat(new Date(a.ersetzt_am))}</small>
|
||
</span></button>
|
||
<div class="acc-b" id="rfarch${i}">
|
||
<div class="feld"><label>Ersetzt am</label><input type="date" value="${esc(a.ersetzt_am ?? "")}" data-rfa="${a.id}|ersetzt_am"></div>
|
||
<div class="feld"><label>Gefahrene Strecke</label>
|
||
<span class="mitEinheit"><input type="number" step="0.1" min="0" value="${a.km ?? ""}" data-rfa="${a.id}|km"><span>km</span></span></div>
|
||
${feld(a, "marke", "Hersteller")}
|
||
${feld(a, "modell", "Modell")}
|
||
${feld(a, "mass", "Größe")}
|
||
${feld(a, "dot", "DOT")}
|
||
<div class="feld"><label>Solldruck vorn / hinten</label>
|
||
<span class="mitEinheit">
|
||
<input class="druckfeld" value="${esc(a.druck_vorne ?? "")}" data-rfa="${a.id}|druck_vorne" placeholder="–">
|
||
<span>/</span>
|
||
<input class="druckfeld" value="${esc(a.druck_hinten ?? "")}" data-rfa="${a.id}|druck_hinten" placeholder="–">
|
||
<span>bar</span>
|
||
</span>
|
||
</div>
|
||
<div class="feld feld--breit"><label>Kommentar</label>
|
||
<input value="${esc(a.kommentar ?? "")}" data-rfa="${a.id}|kommentar" placeholder="–"></div>
|
||
<button class="aktion loeschen" style="margin-top:10px" data-reifenarchivloeschen="${a.id}">Löschen</button>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
|
||
Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander-
|
||
laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die
|
||
Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch,
|
||
Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet
|
||
das Backend ab. */
|
||
/* Gespeichert bleibt der kleingeschriebene Schlüssel - er wird an mehreren
|
||
Stellen verglichen (Pille auf der Einzelfahrt, Statistik "Arbeitsweg", CSV)
|
||
und steht so auch in allen bereits erfassten Fahrten. Angezeigt wird er
|
||
groß, deshalb artText() statt eines geänderten Werts. */
|
||
const FAHRT_ARTEN = ["privat", "arbeitsweg"];
|
||
const artText = (a) => (a ? a.charAt(0).toUpperCase() + a.slice(1) : "");
|
||
|
||
/* "1 h 15 min" statt "75 min" - bei mehrstündigen Fahrten liest sich die
|
||
reine Minutenzahl schlecht. Gleiche Schreibweise wie companion-app/src/
|
||
format.ts' dauer() (Paritätsregel), zweistellig gepolsterte Minuten. */
|
||
function dauerText(sekunden) {
|
||
if (sekunden == null || !isFinite(sekunden)) return "–";
|
||
const min = Math.max(0, Math.round(sekunden / 60));
|
||
const std = Math.floor(min / 60);
|
||
return std ? `${std} h ${String(min % 60).padStart(2, "0")} min` : `${min} min`;
|
||
}
|
||
|
||
/* Start und Ankunft eines Formulars als echte Zeitpunkte. Liegt die Ankunft
|
||
vor der Abfahrt, wurde über Mitternacht gefahren - dann zählt der Folgetag.
|
||
null, solange Datum oder eine der beiden Uhrzeiten fehlt. */
|
||
function fahrtZeitraum(praefix) {
|
||
const datum = $(`#${praefix}Datum`).value, ab = $(`#${praefix}Start`).value, an = $(`#${praefix}Ende`).value;
|
||
if (!datum || !ab || !an) return null;
|
||
const start = new Date(`${datum}T${ab}:00`);
|
||
let ende = new Date(`${datum}T${an}:00`);
|
||
if (isNaN(start) || isNaN(ende)) return null;
|
||
if (ende <= start) ende = new Date(ende.getTime() + dTag);
|
||
return { start, ende };
|
||
}
|
||
function fahrtDauerText(praefix) {
|
||
const z = fahrtZeitraum(praefix);
|
||
return z ? dauerText((z.ende - z.start) / 1000) : "–";
|
||
}
|
||
|
||
/* Datum für ein <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>Start</dt><dd>${dezeitpunkt(new Date(t.ts_start))}<small>${esc(t.start_address || "unbekannt")}</small></dd></div>
|
||
<div class="row"><dt>Ziel</dt><dd>${dezeitpunkt(new Date(t.ts_end))}<small>${esc(t.end_address || "unbekannt")}</small></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>${dauerText(t.duration_s)}</dd></div>
|
||
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</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)} → ${esc(b)}`;
|
||
}
|
||
|
||
/* A1.1: Der Verbrauch einer einzelnen Fahrt ist eine Näherung aus der
|
||
Literstand-Differenz über die Fahrt (verbrauch_aus_literstaenden() im
|
||
Backend) - kein vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert
|
||
(Trip-Detail zeigt das schon korrekt als "Näherung aus Tankfüllstand",
|
||
siehe oben). */
|
||
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
|
||
/* Mittelwert der genäherten Fahrtverbräuche, nach Distanz gewichtet. */
|
||
function schnittFahrtVerbrauch(list) {
|
||
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
|
||
const km = sum(mit, (t) => t.distance_km);
|
||
return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–";
|
||
}
|
||
/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum
|
||
letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der
|
||
Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte
|
||
oder ein Tankvorgang noch unvollständig war. */
|
||
function langzeitverbrauch() {
|
||
const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
|
||
const letzter = tanks[tanks.length - 1];
|
||
const bis = new Date(letzter.ts);
|
||
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("Näherung je Fahrt, 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 sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</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: "Wartungsplan-Eintrag löschen?",
|
||
bild: "Foto löschen?",
|
||
batt: "Messwert löschen?",
|
||
};
|
||
const LOESCH_HINWEIS = {
|
||
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
|
||
fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.",
|
||
sbuch: "Der Eintrag wird aus dem Wartungsplan entfernt. Das lässt sich nicht widerrufen.",
|
||
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
|
||
batt: "Der Tageseintrag wird aus der Messwertliste entfernt. Das lässt sich nicht widerrufen.",
|
||
};
|
||
function eintragLoeschen(art, id) {
|
||
if (art === "trip") {
|
||
serviceRufen("fahrt_loeschen", { trip_id: id });
|
||
TRIPS = TRIPS.filter((t) => t.trip_id !== id);
|
||
} else if (art === "fill") {
|
||
serviceRufen("tankvorgang_loeschen", { tank_id: id });
|
||
FILLS = FILLS.filter((f) => f.tank_id !== id);
|
||
} else if (art === "sbuch") {
|
||
CAR.service.buch.splice(parseInt(id, 10), 1);
|
||
profilSpeichern();
|
||
} else if (art === "batt") {
|
||
serviceRufen("batterieverlauf_loeschen", { datum: id });
|
||
BATTERIEVERLAUF = BATTERIEVERLAUF.filter((e) => e.datum !== id);
|
||
}
|
||
}
|
||
|
||
/* 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"];
|
||
|
||
/* Eigene Berechnung als RÜCKFALL für "Gefahrene Distanz" bei einer NEUEN
|
||
Erfassung, wenn kein TANK_DISTANZ_SENSOR zugeordnet ist oder dieser gerade
|
||
keinen gültigen Wert liefert (siehe distanzVorschlag() unten): Differenz
|
||
zum zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe
|
||
Regel wie backendseitig in ablage.distanz_seit_letzter_tankung(). Beim
|
||
Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus
|
||
f.distance_km (siehe Aufrufer). */
|
||
function distanzVorschlagBerechnet(aktuellerKm) {
|
||
if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return "";
|
||
const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
|
||
if (letzte.odometer_km == null) return "";
|
||
const distanz = Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10;
|
||
// Ein negativer Wert ist nie eine gefahrene Distanz - kann bei einer
|
||
// fehlerhaft/verzögert erfassten Kilometerstand-Basis des letzten
|
||
// Tankvorgangs vorkommen (z. B. ein "auto" erkannter Vorgang, dessen
|
||
// km-Basis über einen späteren, korrigierten/niedrigeren Kilometerstand
|
||
// hinausragt). Lieber kein Vorschlag als ein irreführender negativer.
|
||
return distanz < 0 ? "" : distanz;
|
||
}
|
||
|
||
/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: bevorzugt den
|
||
aktuellen Live-Wert von TANK_DISTANZ_SENSOR (vom Fahrzeug selbst berechnet,
|
||
z. B. FMM003s "calculated_distance_covered_after_the_latest_refueling"),
|
||
fällt ohne Zuordnung oder bei einem gerade nicht-numerischen Zustand
|
||
(unavailable/unknown) auf distanzVorschlagBerechnet() zurück - dieselbe
|
||
"Sensor bevorzugt, eigene Berechnung als Rückfall"-Reihenfolge wie
|
||
backendseitig in distanz_seit_tankung() (tankerkennung.py), hier nur zur
|
||
Vorbefüllung im Formular, da der neue Datensatz noch nicht existiert. */
|
||
function distanzVorschlag(aktuellerKm) {
|
||
const id = ENTITAETEN && ENTITAETEN.werte && ENTITAETEN.werte.TANK_DISTANZ_SENSOR;
|
||
if (id && HASS && HASS.states[id]) {
|
||
const wert = parseFloat(HASS.states[id].state);
|
||
if (Number.isFinite(wert)) return wert;
|
||
}
|
||
return distanzVorschlagBerechnet(aktuellerKm);
|
||
}
|
||
|
||
function tankFelder(praefix, f) {
|
||
// Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf)
|
||
// überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen
|
||
// immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2).
|
||
const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null;
|
||
const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f;
|
||
const ts = q && q.ts ? new Date(q.ts) : new Date();
|
||
const datum = ts.toISOString().slice(0, 10);
|
||
const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0");
|
||
const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : "");
|
||
const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km);
|
||
const liter = q && q.liters != null ? q.liters : "";
|
||
const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : "";
|
||
const ersparnis = q && q.discount != null ? q.discount : "";
|
||
const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0];
|
||
const station = q && q.station_name ? q.station_name : "";
|
||
const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0
|
||
? `Preis je Liter: ${de(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>
|
||
<span class="mitEinheit"><input type="number" id="${praefix}Km" value="${km}"><span>km</span></span></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>
|
||
<span class="mitEinheit"><input type="number" step="0.01" id="${praefix}Liter" data-tankpreisfeld="${praefix}" value="${liter}" placeholder="0,00"><span>l</span></span></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"/>',
|
||
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
|
||
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders
|
||
als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand
|
||
lassen, ~32-47% Fuellflaeche laut Live-Messung per getBBox()) ist das
|
||
CI-Symbol randlos gezeichnet (~92% Fuellflaeche) und wirkte dadurch neben
|
||
"Fahrten"/"Tanken" sichtbar zu gross. Zentrierte Stauchung auf das
|
||
gemessene Grössenverhältnis (Faktor 0.77, um (12,12) - auf Nutzerwunsch
|
||
10% größer als die erste, auf ~45% Fuellflaeche gerechnete Fassung)
|
||
statt einer eigenen Icon-Groesse: bleibt so bei jeder Breakpoint-Groesse
|
||
(.tab svg 26px/22px) automatisch im richtigen Verhältnis, ohne zwei
|
||
Stellen pflegen zu müssen. */
|
||
stat: `<g transform="translate(2.76,2.76) scale(0.77)">${CI.pollsS}</g>`,
|
||
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", "stat"]);
|
||
let route = { name: "home" };
|
||
const ZURUECK = {
|
||
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
|
||
vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails",
|
||
vertrag: "vers",
|
||
steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home",
|
||
battverlauf: "audi",
|
||
battliste: "battverlauf",
|
||
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
|
||
Zurück-Pfeil. */
|
||
sicherheit: "home",
|
||
standort: "home",
|
||
tuerenklappen: "sicherheit",
|
||
};
|
||
const ICON = {
|
||
sonne: '<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>`;
|
||
}
|
||
|
||
/* ------------------------------------------------- Datensatz-Popup
|
||
"Fahrzeugprofil"-Kachel in den Einstellungen: "Datensatz sichern" öffnet
|
||
vier sofortige, rein clientseitige Exporte (Fahrzeugprofil als JSON,
|
||
Fahrten/Tankvorgänge/Wartungsplan als CSV - identisch zum bisherigen
|
||
Verhalten der jetzt zusammengelegten "Fahrzeugprofil"/"Daten
|
||
ausgeben"-Kacheln). "Datensatz laden" spiegelt dieselben vier Dateien als
|
||
Datei-Auswahl - Fahrzeugprofil läuft wie zuvor über den bestehenden
|
||
profil_schreiben-Dienst, die drei CSV-Datensätze über den neuen
|
||
csv_importieren-Dienst (csv_import.py): eine Zeile mit bekannter ID
|
||
aktualisiert nur ihre eigenen Spalten, alles andere am Datensatz bleibt
|
||
unangetastet - siehe dessen Moduldocstring für die Begründung. */
|
||
let datensatzPopup = null; // { modus: "sichern" | "laden" } | null
|
||
let datensatzLaeuft = null; // welcher Datensatz-Import gerade läuft, oder null
|
||
let datensatzErgebnis = null; // { art, ... } des zuletzt gelaufenen Imports, oder null
|
||
let datensatzFehler = null;
|
||
|
||
const DATENSATZ_ARTEN = [
|
||
["fahrzeugprofil", "Fahrzeugprofil"],
|
||
["fahrten", "Fahrten"],
|
||
["tanken", "Tankvorgänge"],
|
||
["service", "Wartungsplan"],
|
||
];
|
||
|
||
function datensatzExportieren(art) {
|
||
if (art === "fahrzeugprofil") {
|
||
const a = document.createElement("a");
|
||
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
|
||
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
|
||
} else if (art === "fahrten") {
|
||
csvDatei("fahrten.csv", ["ID", "Start", "Ende", "km", "Art", "Status"],
|
||
TRIPS.map((t) => [t.trip_id, t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
|
||
} else if (art === "tanken") {
|
||
csvDatei("tankvorgaenge.csv", ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
|
||
FILLS.map((f) => [f.tank_id, f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
|
||
} else if (art === "service") {
|
||
csvDatei("wartungsplan.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
|
||
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
|
||
}
|
||
}
|
||
|
||
function datensatzErgebnisText(e) {
|
||
if (e.art === "fahrzeugprofil") return "Fahrzeugprofil geladen.";
|
||
const teile = [];
|
||
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`);
|
||
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`);
|
||
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`);
|
||
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden.";
|
||
}
|
||
|
||
function datensatzImportieren(art, datei) {
|
||
const leser = new FileReader();
|
||
leser.onerror = () => {
|
||
datensatzLaeuft = null;
|
||
datensatzFehler = "Die Datei ließ sich nicht lesen.";
|
||
render();
|
||
};
|
||
leser.onload = () => {
|
||
if (art === "fahrzeugprofil") {
|
||
try {
|
||
const p = JSON.parse(leser.result);
|
||
PROFIL_ROH = p; CONFIG = profilZuConfig(p);
|
||
CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
|
||
bild = 0;
|
||
datensatzLaeuft = null; datensatzErgebnis = { art }; datensatzFehler = null;
|
||
} catch (err) {
|
||
datensatzLaeuft = null;
|
||
datensatzFehler = "Die Datei ließ sich nicht lesen: " + err.message;
|
||
}
|
||
render();
|
||
return;
|
||
}
|
||
HASS.callService(DOMAIN, "csv_importieren", { art, inhalt: leser.result })
|
||
.then(async () => {
|
||
// importStatusLesen() fragt den Zustand frisch per get_states ab,
|
||
// statt aus dem lokal zwischengespeicherten HASS.states zu lesen -
|
||
// dessen state_changed-Update kommt über eine eigene
|
||
// Websocket-Nachricht rein und muss dem Rückgabewert dieses
|
||
// Dienstaufrufs nicht unbedingt schon vorausgegangen sein (dieselbe
|
||
// Begründung wie bei importAusloesen() oben, nur ohne dessen
|
||
// Warteschleife - der CSV-Import läuft synchron im Backend, bis
|
||
// hierhin ist der neue Stand schon veröffentlicht).
|
||
const s = await importStatusLesen();
|
||
datensatzLaeuft = null;
|
||
datensatzErgebnis = { art, ...((s && s.attributes && s.attributes.daten) || {}) };
|
||
datensatzFehler = null;
|
||
render();
|
||
datenAktualisieren();
|
||
})
|
||
.catch((err) => {
|
||
datensatzLaeuft = null;
|
||
datensatzFehler = "Der Import ist fehlgeschlagen: " + ((err && err.message) || "unbekannter Fehler");
|
||
render();
|
||
});
|
||
};
|
||
datensatzLaeuft = art;
|
||
datensatzErgebnis = null; datensatzFehler = null;
|
||
render();
|
||
leser.readAsText(datei);
|
||
}
|
||
|
||
function vDatensatzPopup() {
|
||
if (!datensatzPopup) return "";
|
||
const sichern = datensatzPopup.modus === "sichern";
|
||
return `<div class="beleg-catcher" data-datensatzzu></div>
|
||
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="${sichern ? "Datensatz sichern" : "Datensatz laden"}">
|
||
<span class="label">${sichern ? "Datensatz sichern" : "Datensatz laden"}</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">
|
||
${sichern
|
||
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
|
||
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen - eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
|
||
</span>
|
||
${DATENSATZ_ARTEN.map(([art, label]) => sichern
|
||
? `<button class="aktion" style="margin-top:12px" data-datensatzsichern="${art}">${label}${art === "fahrzeugprofil" ? " sichern" : " als CSV"}</button>`
|
||
: `<label class="aktion" style="display:block;text-align:center;cursor:pointer;margin-top:12px">
|
||
${datensatzLaeuft === art ? "Lädt …" : label + (art === "fahrzeugprofil" ? " laden" : " (CSV) laden")}
|
||
<input type="file" accept="${art === "fahrzeugprofil" ? "application/json" : "text/csv"}"
|
||
data-datensatzladen="${art}" hidden ${datensatzLaeuft ? "disabled" : ""}></label>`
|
||
).join("")}
|
||
${datensatzErgebnis ? `<div style="margin-top:14px;font-size:13.5px;line-height:1.6;color:var(--fg)">${esc(datensatzErgebnisText(datensatzErgebnis))}</div>` : ""}
|
||
${datensatzFehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(datensatzFehler)}</span>` : ""}
|
||
<button class="aktion primaer" style="margin-top:16px" data-datensatzzu>Fertig</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>`;
|
||
|
||
/* Gemeinsames Präfix zweier domain-freier IDs, bis zum letzten gemeinsamen
|
||
"_" - oder null, wenn sie nicht einmal ein volles Segment teilen. */
|
||
function praefixPaar(a, b) {
|
||
let i = 0;
|
||
while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++;
|
||
const idxUnter = a.slice(0, i).lastIndexOf("_");
|
||
return idxUnter > 0 ? a.slice(0, idxUnter + 1) : null;
|
||
}
|
||
|
||
/* Alle aktuell im Setup zugeordneten Entity-IDs (domain-frei), außer der
|
||
übergebenen selbst - Vergleichsbasis für entitaetIdKurz() unten. */
|
||
function setupAndereIdsOhneDomain(ausser) {
|
||
const ids = [];
|
||
for (const feld of setupKatalog()) {
|
||
const wert = setupZuordnung[feld.key];
|
||
if (feld.liste) { for (const w of (wert || [])) if (w && w !== ausser) ids.push(w); }
|
||
else if (wert && wert !== ausser) ids.push(wert);
|
||
}
|
||
return ids.map((id) => id.includes(".") ? id.split(".").slice(1).join(".") : id);
|
||
}
|
||
|
||
/* Die Entity-ID ohne Domain und ohne das installationsspezifische Präfix -
|
||
die tatsächliche, stabile Kennung des zugeordneten Sensors, unabhängig
|
||
davon, wie Geräte-Slug und Integrationsinstanz beim jeweiligen Nutzer
|
||
heißen (z. B. "testcar_b9_fmm003_testintegratoin_" oder
|
||
"audi_rs_4_avant_").
|
||
|
||
Das Präfix ist NICHT das mit dem einzelnen nächsten Verwandten geteilte
|
||
(frühere Fassung) - das kürzte in der Praxis zu viel: zwei Felder
|
||
derselben Sensorquelle können selbst wieder ein gemeinsames Namens-
|
||
Teilstück haben, das nichts mit der Installation zu tun hat (z. B. teilen
|
||
sich "oil_change_due" und "oil_change_distance" das Wortstück
|
||
"oil_change_", "can_fuel_volume" teilt sich "can_" mit einem anderen
|
||
CAN-Sensor) - genau das hat der Nutzer live beobachtet: "can_fuel_volume"
|
||
wurde zu bloß "fuel_volume", "oil_change_due" zu bloß "due". Stattdessen
|
||
zählt jedes Präfix, das eine Entität mit irgendeiner ANDEREN teilt, als
|
||
eine Stimme; das Präfix mit den MEISTEN Stimmen gewinnt. Das echte
|
||
Installationspräfix teilt sich die Entität mit fast allen anderen
|
||
Sensoren ihrer Quelle (viele Stimmen), ein zufällig geteiltes
|
||
Namens-Teilstück nur mit dem einen verwandten Feld (eine Stimme) - die
|
||
Mehrheit entscheidet also richtig, ohne einen einzelnen "nächsten"
|
||
Nachbarn zu bevorzugen. Bei Gleichstand gewinnt das kürzere Präfix
|
||
(vorsichtiger - weniger Kürzung im Zweifel). Mehrere gleichzeitige
|
||
Sensorquellen (z. B. FMM003 und eine zweite, "audi_rs_4_avant_"-
|
||
präfixte Integration) bleiben dabei weiterhin unabhängig voneinander
|
||
korrekt gekürzt, da die Stimmen pro Entität und nicht global gezählt
|
||
werden. Findet sich kein Verwandter mit gemeinsamem Präfix, bleibt die
|
||
volle domain-freie ID stehen statt einer geratenen, möglicherweise
|
||
falschen Kürzung. */
|
||
function entitaetIdKurz(entityId) {
|
||
if (!entityId) return null;
|
||
const ohneDomain = entityId.includes(".") ? entityId.split(".").slice(1).join(".") : entityId;
|
||
const stimmen = new Map();
|
||
for (const andere of setupAndereIdsOhneDomain(entityId)) {
|
||
const kandidat = praefixPaar(ohneDomain, andere);
|
||
if (!kandidat) continue;
|
||
stimmen.set(kandidat, (stimmen.get(kandidat) || 0) + 1);
|
||
}
|
||
let bestesPraefix = null, besteStimmen = 0;
|
||
for (const [kandidat, anzahl] of stimmen) {
|
||
const besser = anzahl > besteStimmen || (anzahl === besteStimmen && bestesPraefix && kandidat.length < bestesPraefix.length);
|
||
if (besser) { bestesPraefix = kandidat; besteStimmen = anzahl; }
|
||
}
|
||
return bestesPraefix ? ohneDomain.slice(bestesPraefix.length) : ohneDomain;
|
||
}
|
||
|
||
/* Die Art des erwarteten Werts, als kurzes Klammer-Etikett direkt neben der
|
||
Feld-Überschrift ("Tankfüllstand [%]", "12V-Batteriespannung [V]") - eine
|
||
feste Eigenschaft der FELD-ROLLE selbst (aus dem FELDER-Katalog,
|
||
einstellungen.py), unabhängig davon, ob und welcher Sensor gerade
|
||
zugeordnet ist. Bewusst NICHT der Live-Wert eines Sensors - der gehört
|
||
nirgends mehr hin, siehe entitaetIdKurz()'s eigenen Kommentar zur
|
||
Kopfzeile. binary_sensor liefert immer den Rohzustand "on"/"off"
|
||
(unabhängig von device_class - die beeinflusst nur, wie ANDERE
|
||
HA-Oberflächen ihn anzeigen, nicht den Rohwert, den diese App liest).
|
||
Bei mehreren möglichen Einheiten (z. B. "L"/"l") die klein geschriebene
|
||
Variante bevorzugt, sonst die erste genannte. Ohne passenden Fall (Knopf,
|
||
GPS-Koordinate) kein Etikett. */
|
||
function feldEinheitAnzeige(feld) {
|
||
if (feld.domains && feld.domains.includes("binary_sensor")) return "on/off";
|
||
if (feld.domains && feld.domains.includes("button")) return null;
|
||
if (feld.units && feld.units.length) {
|
||
return feld.units.find((u) => u === u.toLowerCase() && u !== u.toUpperCase()) || feld.units[0];
|
||
}
|
||
if (feld.device_classes && (feld.device_classes.includes("date") || feld.device_classes.includes("timestamp"))) {
|
||
return "Datum";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) {
|
||
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) {
|
||
// Bei einem Listenfeld (vier Positionen wie Tür-/Fenstersensoren) gibt es
|
||
// keinen EINEN zugeordneten Sensor, dessen Kennung sich anzeigen ließe -
|
||
// jede Position hat ihre eigene Zuordnung, siehe die Unterfelder unten.
|
||
// Das Einheiten-Etikett neben dem Label gilt dagegen für die ganze Rolle
|
||
// und wird auch dort gezeigt.
|
||
const einheit = feldEinheitAnzeige(feld);
|
||
const erwartet = feld.liste ? "" : entitaetIdKurz(setupZuordnung[feld.key]);
|
||
// Nutzerkorrektur 2026-08-28: kein erfundenes statisches Beispiel mehr (war
|
||
// für Reichweite/Sofort-Aktualisierung schlicht falsch) - nur EINE Zeile,
|
||
// der tatsächlich zugeordnete Sensor (erwartet), als "(z. B. ...)"
|
||
// dargestellt. Der Klammertext selbst in Label-Größe/-Farbe, der Sensorname
|
||
// darin in derselben kleinen/grauen Schrift wie die Einheit ("[on/off]").
|
||
const hinweis = erwartet
|
||
? `<span class="setup-feld-hinweis">(z. B. <span class="setup-feld-einheit">${esc(erwartet)}</span>)</span>`
|
||
: "";
|
||
const kopf = `<div class="setup-feld-kopf">
|
||
<span class="setup-feld-label">${esc(feld.label)}${einheit ? ` <span class="setup-feld-einheit">[${esc(einheit)}]</span>` : ""}</span>
|
||
${hinweis}
|
||
</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 === "battliste") { head = ["Batteriespannung", "Messwerte"]; v.innerHTML = vBatterieliste(); }
|
||
else if (route.name === "sbuch") { head = ["Wartungsplan", "Eintrag"]; v.innerHTML = vSbuch(); }
|
||
else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); }
|
||
else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title];
|
||
v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); }
|
||
$("#eyebrow").textContent = head[0]; $("#title").textContent = head[1];
|
||
/* B3: Push nach rechts hinein, Pop nach links heraus. */
|
||
v.classList.remove("push-rein", "push-raus");
|
||
if (navRichtung && !gleicheAnsicht) {
|
||
void v.offsetWidth;
|
||
v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein");
|
||
}
|
||
navRichtung = null;
|
||
// .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays -
|
||
// die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop
|
||
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
|
||
// auf 0 zurueck).
|
||
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
|
||
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup() + vDatensatzPopup();
|
||
if (setupOffen) {
|
||
const koerper = ROOT.querySelector(".setup-koerper");
|
||
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
|
||
}
|
||
// Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element
|
||
// (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst
|
||
// müsste man nach jedem Tippen erneut anklicken.
|
||
if (setupOffen && setupSucheOffen) {
|
||
const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`);
|
||
if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); }
|
||
}
|
||
/* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht
|
||
dort der Zurück-Pfeil. */
|
||
ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none";
|
||
const sy = $("#sync");
|
||
sy.style.display = route.name === "home" ? "flex" : "none";
|
||
sy.querySelector("span").textContent = standAlterText();
|
||
b.classList.toggle("on", !istTab(route.name));
|
||
v.scrollTop = gleicheAnsicht ? merkeScroll : 0;
|
||
letzteAnsicht = route.name + "|" + (route.id || "");
|
||
/* Die Karten haengen an DOM-Knoten, die render() ueber v.innerHTML gerade
|
||
ersetzt hat - eine ueberlebende Leaflet-Instanz zeigte danach auf ein
|
||
abgehaengtes Element und bliebe leer. Sie muessen deshalb bei jedem
|
||
Durchlauf neu aufgebaut werden; die Haeufigkeit begrenzt stattdessen die
|
||
Aenderungspruefung in datenLaden(). */
|
||
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
||
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
|
||
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 dsOeffnen = e.target.closest("[data-datensatz]");
|
||
if (dsOeffnen) {
|
||
datensatzPopup = { modus: dsOeffnen.dataset.datensatz };
|
||
datensatzLaeuft = null; datensatzErgebnis = null; datensatzFehler = null;
|
||
render(); return;
|
||
}
|
||
if (e.target.closest("[data-datensatzzu]")) {
|
||
if (datensatzLaeuft) return;
|
||
datensatzPopup = null; render(); return;
|
||
}
|
||
const dsExport = e.target.closest("[data-datensatzsichern]");
|
||
if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; }
|
||
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 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 satz = e.target.closest("[data-satz]");
|
||
if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
|
||
const rfArch = e.target.closest("[data-reifenarchivieren]");
|
||
if (rfArch) {
|
||
const k = rfArch.dataset.reifenarchivieren;
|
||
bestaetigen("Neue Räder anlegen?",
|
||
"Der aktuelle Stand wird ins Archiv gelegt, der laufende Satz beginnt neu bei 0 km.",
|
||
"Anlegen", () => { serviceRufen("reifen_archivieren", { satz: k }); }, false);
|
||
return;
|
||
}
|
||
const rfArchDel = e.target.closest("[data-reifenarchivloeschen]");
|
||
if (rfArchDel) {
|
||
const id = rfArchDel.dataset.reifenarchivloeschen;
|
||
bestaetigen("Archivierten Reifensatz löschen?",
|
||
"Das lässt sich nicht widerrufen.", "Löschen", () => {
|
||
serviceRufen("reifen_archiv_loeschen", { id });
|
||
CAR.reifen.archiv = CAR.reifen.archiv.filter((a) => a.id !== id);
|
||
render();
|
||
}, true);
|
||
return;
|
||
}
|
||
const k = e.target.closest("[data-ics]");
|
||
if (k) {
|
||
if (k.dataset.ics === "service") {
|
||
const 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.datensatzladen) {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
datensatzImportieren(e.target.dataset.datensatzladen, datei);
|
||
return;
|
||
}
|
||
if (e.target.dataset.backup === "import") {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const leser = new FileReader();
|
||
leser.onload = () => {
|
||
try {
|
||
const paket = JSON.parse(leser.result);
|
||
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)");
|
||
PROFIL_ROH = paket.profil;
|
||
CONFIG = profilZuConfig(paket.profil);
|
||
CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||
TRIPS = paket.fahrten || [];
|
||
FILLS = paket.tankvorgaenge || [];
|
||
serviceRufen("backup_wiederherstellen", {
|
||
profil_json: JSON.stringify(paket.profil),
|
||
fahrten_json: JSON.stringify(TRIPS),
|
||
tankvorgaenge_json: JSON.stringify(FILLS),
|
||
// Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die
|
||
// aktuelle Zuordnung stehen, statt geleert zu werden.
|
||
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
|
||
});
|
||
bild = 0; render();
|
||
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
|
||
};
|
||
leser.readAsText(datei);
|
||
return;
|
||
}
|
||
if (e.target.dataset.vcardImport !== undefined) {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const leser = new FileReader();
|
||
leser.onload = () => {
|
||
try {
|
||
const felder = vcardParsen(leser.result);
|
||
if (felder.name) CAR.service.autohaus.name = felder.name;
|
||
if (felder.adresse) CAR.service.autohaus.adresse = felder.adresse;
|
||
if (felder.tel) CAR.service.autohaus.tel = felder.tel;
|
||
if (felder.mail) CAR.service.autohaus.mail = felder.mail;
|
||
profilSpeichern(); render();
|
||
} catch (err) { hinweis("vCard konnte nicht gelesen werden", err.message); }
|
||
};
|
||
leser.readAsText(datei);
|
||
e.target.value = "";
|
||
return;
|
||
}
|
||
if (e.target.dataset.bildupload !== undefined) {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const dateiname = e.target.dataset.bildupload;
|
||
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;
|
||
}
|
||
const rfa = e.target.dataset.rfa;
|
||
if (rfa) {
|
||
const [id, schluessel] = rfa.split("|");
|
||
const wert = schluessel === "km" ? parseFloat(e.target.value) || 0 : e.target.value;
|
||
const eintrag = CAR.reifen.archiv.find((a) => a.id === id);
|
||
if (eintrag) eintrag[schluessel] = wert;
|
||
// Eigener Dienst statt profilSpeichern (siehe profilZuCar()) - ändert
|
||
// nur diesen einen archivierten Satz, statt das ganze Profil (samt
|
||
// eines womöglich seither fortgeschriebenen km-Stands) zu überschreiben.
|
||
serviceRufen("reifen_archiv_aktualisieren", { id, [schluessel]: wert });
|
||
return;
|
||
}
|
||
if (e.target.dataset.schrift !== undefined) {
|
||
beschriftung = e.target.checked;
|
||
try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ }
|
||
ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung);
|
||
return;
|
||
}
|
||
if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.finAutomatisch !== undefined) { CAR.finAutomatisch = e.target.checked; profilSpeichern(); render(); return; }
|
||
const vteil = e.target.dataset.vteil;
|
||
if (vteil) {
|
||
const [i, feld] = vteil.split("|");
|
||
CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value;
|
||
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
|
||
profilSpeichern(); render(); return;
|
||
}
|
||
// Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem
|
||
// render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem
|
||
// Nutzer den gerade offenen Datumswähler weg.
|
||
if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; }
|
||
if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; }
|
||
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
|
||
const vnotruf = e.target.dataset.vnotruf;
|
||
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
|
||
const vvertrag = e.target.dataset.vvertrag;
|
||
if (vvertrag) { CAR.versicherung[vvertrag] = e.target.value; profilSpeichern(); render(); return; }
|
||
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);
|
||
}
|
||
|