440ff22bda
Entfernt die letzten Verweise auf die abgeloeste TommiG1/HA_VAG-EU-Data-Act- Integration (Versionstile, Testumgebungs-Fixture) und ersetzt sie durch die FMM003-Werte. Behebt eine CSS-Spezifitaetskollision, durch die alle iOS- Toggle-Switches viel zu breit gerendert wurden. Dokumentiert und behebt sieben Layout-Befunde aus zwei Design-Audit-Runden (Zahnrad-Ueberlauf auf breiten Bildschirmen, zu kleine Ringe als mobiler Einstellungen-Zugang, zu breite Popups, Kopfzeilen-Versatz, ueberlappende Kachel-Pfeile, fehlender Editierbarkeits-Hinweis bei Auswahlfeldern, Haekchen-artige Auf/Zu-Pfeile) - siehe DESIGN_AUDIT_2026-08-13.md fuer Root-Cause und Beleg je Befund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4031 lines
232 KiB
JavaScript
4031 lines
232 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
|
||
pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die
|
||
DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).
|
||
|
||
Bekannte Lücken in diesem Stand:
|
||
- Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
|
||
vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
|
||
gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
|
||
- Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur,
|
||
wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat.
|
||
*/
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch
|
||
unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`)
|
||
weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel-
|
||
Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine
|
||
Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein
|
||
<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 pyscript.audi_dashboard_entitaeten (Setup-Menü)
|
||
let DATEN_GELADEN = false;
|
||
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
|
||
// null, solange keiner bekannt ist (siehe standAlterText()).
|
||
let LETZTES_UPDATE_TS = null;
|
||
|
||
/* Cache-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit
|
||
derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js
|
||
(siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der
|
||
sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand
|
||
haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu
|
||
geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js
|
||
gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites
|
||
Mal abfragen zu muessen.
|
||
*/
|
||
const CSS_VERSION = (() => {
|
||
try {
|
||
return new URL(import.meta.url).searchParams.get("v") || Date.now();
|
||
} catch (e) {
|
||
return Date.now();
|
||
}
|
||
})();
|
||
|
||
const $ = (s) => ROOT.querySelector(s);
|
||
|
||
/* ---------------------------------------------------------------- Format */
|
||
const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||
const eur = (n) => de(n, 2);
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
const sum = (a, f) => a.reduce((s, x) => s + f(x), 0);
|
||
/* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */
|
||
const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0);
|
||
|
||
/* -------------------------------------------------------- Adapter-Schicht
|
||
Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/
|
||
einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in
|
||
die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus
|
||
dem Prototyp übernommen, um dessen Render-Funktionen unverändert
|
||
weiterzuverwenden. */
|
||
function profilZuConfig(p) {
|
||
return {
|
||
fahrzeugtitel: p.fahrzeug.titel,
|
||
fahrzeugzusatz: p.fahrzeug.zusatz,
|
||
fahrzeuguntertitel: p.fahrzeug.untertitel,
|
||
kennzeichen: p.fahrzeug.kennzeichen,
|
||
startbild: p.einstellungen.uebersichtsbild,
|
||
version: "1.0",
|
||
tankvolumen: p.fahrzeug.tankvolumen_liter,
|
||
nachtVon: p.einstellungen.nacht_von,
|
||
nachtBis: p.einstellungen.nacht_bis,
|
||
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
|
||
markenname: p.einstellungen.kraftstoffanbieter,
|
||
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
||
letztesBackup: p.einstellungen.letztes_backup || null,
|
||
ausfuehrung: p.fahrzeug.ausfuehrung || "",
|
||
};
|
||
}
|
||
|
||
function profilZuCar(p, status) {
|
||
// km ist kein abgeleiteter Wert mehr, sondern wird vom Backend
|
||
// (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene
|
||
// Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz.
|
||
const reifenSatz = (k) => p.reifen.saetze[k] || {};
|
||
return {
|
||
fin: p.fahrzeug.fin,
|
||
details: p.fahrzeug.details,
|
||
erstzulassung: p.fahrzeug.erstzulassung,
|
||
hu: p.fahrzeug.hauptuntersuchung_faellig,
|
||
odo: status.km ?? 0,
|
||
odoBekannt: status.km != null,
|
||
tankPct: status.tankprozent ?? 0,
|
||
tankBekannt: status.tankprozent != null,
|
||
reichweite: status.reichweite_km,
|
||
batteriespannung: status.batteriespannung,
|
||
gesichert: status.gesichert,
|
||
sicherheitscheck: status.sicherheitscheck || [],
|
||
standortLat: status.standort_lat,
|
||
standortLon: status.standort_lon,
|
||
standortGenauigkeitM: status.standort_genauigkeit_m,
|
||
standortZeit: status.standort_zeit,
|
||
oelwechselFaelligTs: status.oelwechsel_faellig_ts,
|
||
oelwechselFaelligKm: status.oelwechsel_faellig_km,
|
||
inspektionFaelligTs: status.inspektion_faellig_ts,
|
||
inspektionFaelligKm: status.inspektion_faellig_km,
|
||
kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert
|
||
// "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag
|
||
// selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne
|
||
// dass der Nutzer manuell eingreifen muss.
|
||
smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab ||
|
||
p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab },
|
||
oel: {
|
||
modus: p.einstellungen.oelwechsel_intervall.modus,
|
||
km: p.einstellungen.oelwechsel_intervall.km,
|
||
monate: p.einstellungen.oelwechsel_intervall.monate,
|
||
herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km,
|
||
herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate,
|
||
},
|
||
versicherung: p.versicherung,
|
||
steuer: { ...p.steuer, faellig: p.steuer.faellig || "" },
|
||
reifen: {
|
||
aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer",
|
||
anzugsmoment: p.reifen.anzugsmoment_nm,
|
||
wechsel: p.reifen.wechsel,
|
||
sommer: reifenSatz("sommer"),
|
||
winter: reifenSatz("winter"),
|
||
},
|
||
service: p.service,
|
||
technik: p.technik,
|
||
ausstattung: p.ausstattung,
|
||
};
|
||
}
|
||
|
||
/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt
|
||
mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */
|
||
function configCarZuProfil(vorherigesProfil) {
|
||
const p = JSON.parse(JSON.stringify(vorherigesProfil));
|
||
p.fahrzeug.titel = CONFIG.fahrzeugtitel;
|
||
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
|
||
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
|
||
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
|
||
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
|
||
p.fahrzeug.fin = CAR.fin;
|
||
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
||
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
||
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
||
p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin;
|
||
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
|
||
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
|
||
p.einstellungen.oelwechsel_intervall = {
|
||
modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate,
|
||
hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate,
|
||
};
|
||
p.versicherung = CAR.versicherung;
|
||
p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null };
|
||
p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase();
|
||
p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment;
|
||
p.reifen.wechsel = CAR.reifen.wechsel;
|
||
for (const k of ["sommer", "winter"]) {
|
||
// km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf
|
||
// den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im
|
||
// Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung
|
||
// überschreiben.
|
||
const { km, ...rest } = CAR.reifen[k];
|
||
p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest };
|
||
}
|
||
p.service = CAR.service;
|
||
return p;
|
||
}
|
||
|
||
let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil()
|
||
|
||
async function profilSpeichern() {
|
||
const neuesProfil = configCarZuProfil(PROFIL_ROH);
|
||
PROFIL_ROH = neuesProfil;
|
||
await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) });
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Bilder
|
||
Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert).
|
||
Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) -
|
||
die Flächen behalten dennoch ihre Maße, das Layout springt nicht. */
|
||
const BILDER = [
|
||
{ name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" },
|
||
{ name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" },
|
||
{ name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" },
|
||
{ name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" },
|
||
{ name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" },
|
||
{ name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" },
|
||
{ name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" },
|
||
];
|
||
let bild = 0;
|
||
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
|
||
// Alle einzeln hochladbaren Bild-Plätze (§ Fahrzeugbilder-Upload in
|
||
// Einstellungen) - dieselben festen Dateinamen wie BILDER, plus die separat
|
||
// geführte Winter-Variante der Seitenansicht.
|
||
const BILDER_UPLOAD_SLOTS = [...BILDER, { name: "Seitenansicht (Winter)", label: SEITE_WINTER.label, datei: SEITE_WINTER.datei }];
|
||
// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als
|
||
// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo()
|
||
// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild
|
||
// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen.
|
||
let bildVersion = Date.now();
|
||
|
||
/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a
|
||
noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol
|
||
plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt -
|
||
damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss,
|
||
ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS-
|
||
Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */
|
||
/* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der
|
||
Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen
|
||
(siehe BILDER_UPLOAD_SLOTS). Die Maße bleiben über die CSS-Klassen erhalten,
|
||
das Layout springt beim Nachliefern eines Fotos nicht. */
|
||
function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) {
|
||
const idTeil = imgId ? ` id="${imgId}"` : "";
|
||
return `<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);
|
||
return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg") +
|
||
`<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="/local/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;
|
||
box.querySelector(".platzhalter-datei").textContent = info.datei;
|
||
}
|
||
const sz = ROOT.getElementById("szene");
|
||
if (sz) sz.className = "szene " + szeneKlasse(bild);
|
||
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild));
|
||
}
|
||
|
||
/* ---------------------------------------------------- Termine/Vorhersage
|
||
heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein
|
||
festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch
|
||
das tatsächliche Tagesdatum ersetzt werden muss. */
|
||
function heute() { return new Date(); }
|
||
const dTag = 86400000;
|
||
/* Ein unlesbares Datum ergibt einen Strich, keine Rechenreste. dat() erwartet
|
||
TT.MM.JJJJ; steht im Profil etwas anderes (es wird laut INSTALL.md von Hand
|
||
gepflegt, z. B. versehentlich im ISO-Format), lieferte mmjj() sonst
|
||
"NaN/N" - ein Wert, der wie eine Angabe aussieht und keine ist. */
|
||
const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime());
|
||
const mmjj = (d) => (gueltigesDatum(d)
|
||
? String(d.getMonth() + 1).padStart(2, "0") + "/" + String(d.getFullYear()).slice(2)
|
||
: "–");
|
||
const dedat = (d) => (gueltigesDatum(d)
|
||
? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })
|
||
: "–");
|
||
function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); }
|
||
|
||
const MISCHDAUER = 180;
|
||
function tagesleistung() {
|
||
// §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem
|
||
// Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die
|
||
// Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000.
|
||
if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 };
|
||
const b = CAR.service.buch;
|
||
if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 };
|
||
const alt = b[b.length - 1];
|
||
const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag);
|
||
const langfristig = (CAR.odo - alt.km) / tageGesamt;
|
||
const letzter = b[0];
|
||
const tageSeit = (heute() - dat(letzter.datum)) / dTag;
|
||
const kmSeit = CAR.odo - letzter.km;
|
||
if (tageSeit < 14 || kmSeit <= 0)
|
||
return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km };
|
||
const aktuell = kmSeit / tageSeit;
|
||
const g = Math.min(1, tageSeit / MISCHDAUER);
|
||
return {
|
||
wert: langfristig * (1 - g) + aktuell * g,
|
||
quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt",
|
||
tage: Math.round(tageSeit), km: kmSeit,
|
||
};
|
||
}
|
||
function kmProTag() { return tagesleistung().wert; }
|
||
function prognose(zielKm) {
|
||
if (!CAR.odoBekannt) return { tage: null, datum: null };
|
||
const rest = zielKm - CAR.odo;
|
||
if (rest <= 0) return { tage: 0, datum: heute() };
|
||
const tage = Math.round(rest / kmProTag());
|
||
return { tage, datum: new Date(heute().getTime() + tage * dTag) };
|
||
}
|
||
function ics(titel, datum, ort, notiz) {
|
||
const tag = datum.replace(/-/g, "");
|
||
const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, "");
|
||
const text = [
|
||
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT",
|
||
"UID:" + Date.now() + "@audi-dashboard.local",
|
||
"DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z",
|
||
"DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster,
|
||
"SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""),
|
||
"BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM",
|
||
"END:VEVENT", "END:VCALENDAR",
|
||
].join("\r\n");
|
||
const a = document.createElement("a");
|
||
a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text);
|
||
a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics";
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
}
|
||
function ring(pct) {
|
||
const r = 64, c = 2 * Math.PI * r, on = (c * pct) / 100;
|
||
return `<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;
|
||
const TILES = {
|
||
nacht: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||
tag: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
||
};
|
||
const ATTR = "© OpenStreetMap · CARTO";
|
||
/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die
|
||
CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */
|
||
const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
|
||
const SAT_ATTR = "Tiles © Esri";
|
||
|
||
function leafletLaden() {
|
||
if (window.L) return Promise.resolve();
|
||
if (LEAFLET_LADEND) return LEAFLET_LADEND;
|
||
LEAFLET_LADEND = new Promise((resolve, reject) => {
|
||
const css1 = document.createElement("link");
|
||
css1.rel = "stylesheet";
|
||
css1.href = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css";
|
||
document.head.appendChild(css1);
|
||
const script = document.createElement("script");
|
||
script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js";
|
||
script.onload = resolve;
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
return LEAFLET_LADEND;
|
||
}
|
||
|
||
function mapBox() { return `<div class="mapbox"><div id="map"></div></div>`; }
|
||
|
||
/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte):
|
||
die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest
|
||
hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen
|
||
schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt-
|
||
Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte
|
||
Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */
|
||
function kartenMittelpunkt() {
|
||
const pos = CAR.service.autohaus.pos;
|
||
return pos && pos.length === 2 ? pos : [51.1657, 10.4515];
|
||
}
|
||
|
||
function fakeTrack(a, b, n = 13) {
|
||
const pts = []; let seed = Math.round((a[0] + b[1]) * 1000);
|
||
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; };
|
||
for (let i = 0; i <= n; i++) {
|
||
const t = i / n, k = Math.sin(t * Math.PI);
|
||
pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]);
|
||
}
|
||
pts[0] = a; pts[n] = b; return pts;
|
||
}
|
||
function tiles() {
|
||
if (!MAP || !window.L) return;
|
||
const url = TILES[HOST.dataset.theme];
|
||
if (TILE) MAP.removeLayer(TILE);
|
||
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
|
||
}
|
||
async function initMap() {
|
||
await leafletLaden();
|
||
const el = ROOT.getElementById("map");
|
||
if (!el || typeof window.L === "undefined") return;
|
||
MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false });
|
||
TILE = null; tiles();
|
||
const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537";
|
||
if (route.name === "trip") {
|
||
const t = TRIPS.find((x) => x.trip_id === route.id);
|
||
if (t && t.start_lat != null && t.end_lat != null) {
|
||
const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]);
|
||
const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP);
|
||
track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP));
|
||
window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || ""));
|
||
window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || ""));
|
||
MAP.fitBounds(line.getBounds(), { padding: [26, 26] });
|
||
} else {
|
||
MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke)
|
||
}
|
||
} else if (route.name === "fill") {
|
||
const f = FILLS.find((x) => x.tank_id === route.id);
|
||
if (f && f.station_lat != null) {
|
||
MAP.setView([f.station_lat, f.station_lon], 14);
|
||
window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "<br>" + (f.station_address || ""));
|
||
} else {
|
||
MAP.setView(kartenMittelpunkt(), 11);
|
||
}
|
||
}
|
||
setTimeout(() => MAP && MAP.invalidateSize(), 60);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- Standort
|
||
Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des
|
||
Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst
|
||
durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet-
|
||
Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt
|
||
und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich
|
||
beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein
|
||
kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau
|
||
dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für
|
||
eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort-
|
||
und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus
|
||
(Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die
|
||
Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */
|
||
let USER_POS = null; // {lat, lon, genauigkeit} oder null
|
||
let USER_POS_TS = 0;
|
||
let USER_POS_FEHLER = null;
|
||
let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung)
|
||
let STANDORT_ADRESSE_KEY = null;
|
||
let STANDORTMENU_OFFEN = false;
|
||
let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit"
|
||
let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel
|
||
let FAHRZEUG_MARKER = null, USER_MARKER = null;
|
||
const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss zu --standort-peek in audi-dashboard.css passen
|
||
|
||
function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; }
|
||
|
||
/* Kartenmarkierung: poi-car aus dem Audi-CI-Satz (assets/icons/poi-car.svg).
|
||
Die Spitze sitzt unten, deshalb ankert Leaflet am unteren Rand. */
|
||
function fahrzeugMarkerSVG(px = 34) {
|
||
return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">${CI.poiCar}</svg>`;
|
||
}
|
||
|
||
/* Fahrzustand fürs Menü: "fährt" solange die aktuelle Fahrt noch offen ist
|
||
(siehe fahrterkennung.py), sonst "steht". Ohne echtes Bewegungssignal vom
|
||
Fahrzeug (siehe AGENTS.md, FMM003/flespi offen) ist das die beste Näherung
|
||
aus den bereits vorhandenen Fahrtdaten.
|
||
|
||
parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in
|
||
"Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen
|
||
Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner
|
||
Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */
|
||
function standortZustand() {
|
||
const t = TRIPS[0];
|
||
if (t && t.status === "offen") return { faehrt: true, seitTs: null, parkplatz: false };
|
||
const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null;
|
||
const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null;
|
||
return { faehrt: false, seitTs, parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15) };
|
||
}
|
||
function dauerSeitText(ts) {
|
||
if (ts == null) return "unbekannt";
|
||
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||
const tage = Math.floor(sekunden / 86400);
|
||
const stunden = Math.floor((sekunden % 86400) / 3600);
|
||
const minuten = Math.floor((sekunden % 3600) / 60);
|
||
if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`;
|
||
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
|
||
return `${minuten} Min.`;
|
||
}
|
||
|
||
/* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */
|
||
function distanzMeter(a, b) {
|
||
if (!a || !b) return null;
|
||
const R = 6371000, rad = Math.PI / 180;
|
||
const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad;
|
||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
||
}
|
||
function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; }
|
||
|
||
/* Zeile unter dem Fahrzeugnamen: Abstand Nutzer -> Fahrzeug, sonst der Grund,
|
||
warum er noch fehlt. */
|
||
function standortAbstandText() {
|
||
if (!standortBekannt()) return "";
|
||
if (USER_POS) return distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) + " entfernt";
|
||
return USER_POS_FEHLER || "Abstand wird ermittelt …";
|
||
}
|
||
function standortErfassen() {
|
||
if (!navigator.geolocation) { USER_POS_FEHLER = "Kein Zugriff auf den Standort dieses Geräts"; standortAnsichtAktualisieren(); return; }
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy };
|
||
USER_POS_TS = Date.now(); USER_POS_FEHLER = null;
|
||
standortAnsichtAktualisieren();
|
||
},
|
||
(err) => {
|
||
USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "Standortzugriff verweigert" : "Standort nicht verfügbar";
|
||
// Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die
|
||
// Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die
|
||
// Freigabe verweigert - und jedes Backend-Update loeste eine neue
|
||
// Hochgenauigkeits-Abfrage aus.
|
||
USER_POS_TS = Date.now();
|
||
standortAnsichtAktualisieren();
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
|
||
);
|
||
}
|
||
function standortErfassenFallsNoetig() {
|
||
// Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener
|
||
// Versuch kein Versuch, siehe Fehlerpfad oben.
|
||
if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return;
|
||
standortErfassen();
|
||
}
|
||
|
||
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
|
||
wie die Kartenkacheln oben (TILES) ohne eigenen Schlüssel; bei diesem
|
||
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
|
||
der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits
|
||
dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
|
||
auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an
|
||
derselben Stelle keinen zweiten Abruf auslöst. */
|
||
async function standortAdresseAufloesen(lat, lon) {
|
||
const key = lat.toFixed(3) + "," + lon.toFixed(3);
|
||
if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE;
|
||
try {
|
||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } });
|
||
const daten = await res.json();
|
||
const a = daten.address || {};
|
||
const strasse = [a.road, a.house_number].filter(Boolean).join(" ");
|
||
STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || "";
|
||
} catch (e) {
|
||
// Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner
|
||
// fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle
|
||
// die ganze Sitzung lang als "schon versucht", und es blieben dauerhaft nur
|
||
// die Koordinaten stehen.
|
||
STANDORT_ADRESSE = null;
|
||
return null;
|
||
}
|
||
STANDORT_ADRESSE_KEY = key;
|
||
return STANDORT_ADRESSE;
|
||
}
|
||
function standortAdresseLaden() {
|
||
if (!standortBekannt()) return;
|
||
standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => {
|
||
const el = $("#standortAdresse");
|
||
if (el && adresse) el.textContent = adresse;
|
||
});
|
||
}
|
||
|
||
function standortKartenlink() {
|
||
return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null;
|
||
}
|
||
async function standortTeilen() {
|
||
const url = standortKartenlink();
|
||
if (!url) return;
|
||
const titel = `${CONFIG.fahrzeugtitel} · Standort`;
|
||
if (navigator.share) {
|
||
try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ }
|
||
return;
|
||
}
|
||
try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); }
|
||
catch (e) { hinweis("Teilen nicht möglich", url); }
|
||
}
|
||
|
||
/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und
|
||
ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */
|
||
function standortAnsichtAktualisieren() {
|
||
if (route.name !== "standort") return;
|
||
standortMarkerZeichnen();
|
||
const dist = $("#standortDistanz");
|
||
if (dist) dist.textContent = standortAbstandText();
|
||
}
|
||
|
||
function standortTiles() {
|
||
if (!SMAP || !window.L) return;
|
||
if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE);
|
||
SMAP_TILE = SMAP_STIL === "satellit"
|
||
? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP)
|
||
: window.L.tileLayer(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
|
||
}
|
||
function standortMarkerZeichnen() {
|
||
if (!SMAP || !window.L) return;
|
||
if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; }
|
||
if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; }
|
||
if (standortBekannt()) {
|
||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(38)}</div>`, iconSize: [38, 38], iconAnchor: [18, 36] });
|
||
FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP);
|
||
FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen());
|
||
}
|
||
if (USER_POS) {
|
||
const icon = window.L.divIcon({ className: "user-pin-wrap", html: `<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(TILES[HOST.dataset.theme], { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
|
||
if (standortBekannt()) {
|
||
TMAP.setView([CAR.standortLat, CAR.standortLon], 14);
|
||
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `<div class="fahrzeug-pin">${fahrzeugMarkerSVG(32)}</div>`, iconSize: [32, 32], iconAnchor: [15, 30] });
|
||
window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP);
|
||
} else {
|
||
TMAP.setView(kartenMittelpunkt(), 11);
|
||
}
|
||
standortErfassenFallsNoetig();
|
||
setTimeout(() => TMAP && TMAP.invalidateSize(), 60);
|
||
}
|
||
|
||
function standortMenuElement() { return ROOT.getElementById("standortmenu"); }
|
||
function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; }
|
||
function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; }
|
||
function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); }
|
||
|
||
/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette,
|
||
bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse,
|
||
anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar
|
||
Standort-Abschnitt). */
|
||
let smY0 = null, smOffenBeimStart = false;
|
||
/* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt,
|
||
deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */
|
||
function standortMenuHub() {
|
||
const el = standortMenuElement();
|
||
return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0;
|
||
}
|
||
function standortMenuVerdrahten() {
|
||
ROOT.addEventListener("pointerdown", (e) => {
|
||
if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return;
|
||
smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN;
|
||
const el = standortMenuElement(); if (el) el.style.transition = "none";
|
||
});
|
||
ROOT.addEventListener("pointermove", (e) => {
|
||
if (smY0 === null) return;
|
||
e.preventDefault();
|
||
const el = standortMenuElement(); if (!el) return;
|
||
const dy = e.clientY - smY0;
|
||
const hub = standortMenuHub();
|
||
const basis = smOffenBeimStart ? 0 : hub;
|
||
const versatz = Math.max(0, Math.min(hub, basis + dy));
|
||
el.style.transform = `translateY(${versatz}px)`;
|
||
}, { passive: false });
|
||
ROOT.addEventListener("pointerup", (e) => {
|
||
if (smY0 === null) return;
|
||
const dy = e.clientY - smY0;
|
||
standortMenuGesteBeenden();
|
||
if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); }
|
||
else standortMenuUmschalten();
|
||
});
|
||
/* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung
|
||
uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der
|
||
pointermove-Handler oben ruft dann preventDefault() fuer jede weitere
|
||
Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein
|
||
pointerup kommt, der dann ein dy aus einem veralteten Startpunkt
|
||
auswertet. Die drei anderen Zeigergesten der Datei machen das laengst
|
||
richtig. */
|
||
ROOT.addEventListener("pointercancel", () => {
|
||
if (smY0 === null) return;
|
||
standortMenuGesteBeenden();
|
||
});
|
||
}
|
||
|
||
/* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die
|
||
CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */
|
||
function standortMenuGesteBeenden() {
|
||
const el = standortMenuElement();
|
||
if (el) { el.style.transition = ""; el.style.transform = ""; }
|
||
smY0 = null;
|
||
}
|
||
|
||
function standortKachel() {
|
||
return `<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"/>',
|
||
selectAll: '<path fill="currentColor" d="M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z"/>',
|
||
planRoute: '<path fill="currentColor" d="m18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z"/>',
|
||
upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>',
|
||
fuelRange: '<path fill="currentColor" d="M2 3.5A2.5 2.5 0 0 1 4.5 1h6A2.5 2.5 0 0 1 13 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H13v7h-1V3.5c0-.83-.67-1.5-1.5-1.5h-6C3.67 2 3 2.67 3 3.5V22h4v1H1v-1h1zM4 10h7v1H4zm5 14v-5h1v2h13v-2h1v5h-1v-2H10v2z"/>',
|
||
parking: '<path fill="currentColor" d="M0 2.5A2.5 2.5 0 0 1 2.5 0H23v20.5a2.5 2.5 0 0 1-2.5 2.5H0zM2.5 1C1.67 1 1 1.67 1 2.5V22h19.5c.83 0 1.5-.67 1.5-1.5V1zm11.59 6.35C13.6 7 13.02 7 12.5 7H8v6h4.5c.52 0 1.11-.01 1.59-.35.44-.3.91-1 .91-2.65 0-1.66-.47-2.34-.91-2.65M8 14v3H7V6h5.53c.5 0 1.38 0 2.13.53C15.47 7.09 16 8.16 16 10s-.53 2.9-1.34 3.47a3.6 3.6 0 0 1-2.13.53z"/>',
|
||
};
|
||
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>`
|
||
: `${ciSVG(CI.parking)}<span>${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}</span>`}
|
||
</div>
|
||
<div class="standortmenu-werte">
|
||
${ciSVG(CI.fuelRange)}
|
||
<span>${de(CAR.tankPct)} %</span>
|
||
<span>${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"}</span>
|
||
</div>
|
||
<div class="standortmenu-aktionen">
|
||
<a class="standort-pille primaer"${link ? ` href="${link}" target="_blank" rel="noopener"` : ` aria-disabled="true" style="pointer-events:none;opacity:.4"`}>
|
||
${ciSVG(CI.planRoute, 15)}Route
|
||
</a>
|
||
<button type="button" class="standort-pille" data-standort-teilen ${bekannt ? "" : "disabled"}>
|
||
${ciSVG(CI.upload, 15)}Teilen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Ansichten
|
||
Ab hier praktisch unverändert aus dem Prototyp übernommen. */
|
||
function badge() {
|
||
// Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen,
|
||
// negative = für dunklen Hintergrund) - welche Variante sichtbar ist,
|
||
// entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in
|
||
// audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces
|
||
// beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig.
|
||
//
|
||
// Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus
|
||
// dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix
|
||
// ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt
|
||
// aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten
|
||
// CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE).
|
||
const praefix = badgePraefix();
|
||
if (!praefix) return "";
|
||
const datei = BADGE_DATEIEN[praefix];
|
||
const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim();
|
||
/* Die Ausfuehrung steht oft schon im Modellnamen ("RS 4 Avant competition"
|
||
plus Ausfuehrung "competition") - dann nicht ein zweites Mal anhaengen,
|
||
sonst steht dort "Avant competition competition". */
|
||
const ausfuehrung = (CONFIG.ausfuehrung || "").trim();
|
||
const doppelt = ausfuehrung && modellZusatz.toLowerCase().includes(ausfuehrung.toLowerCase());
|
||
const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" ");
|
||
const alt = esc(CONFIG.fahrzeugtitel) + " Badge";
|
||
return `<div class="badge">
|
||
<img class="positive" src="/local/badges/${datei}-positive.svg" alt="${alt}">
|
||
<img class="negative" src="/local/badges/${datei}-negative.svg" alt="${alt}">
|
||
${zusatz ? `<span class="zusatz">${esc(zusatz)}</span>` : ""}</div>`;
|
||
}
|
||
|
||
function teaser() {
|
||
if (!TRIPS.length && !FILLS.length) return "";
|
||
const t = TRIPS[0], f = FILLS[0];
|
||
return `<div class="tile"><span class="label">Zuletzt</span>
|
||
${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
|
||
<span class="k">Letzte Fahrt<small>${esc(t.start_address || "")} → ${esc(t.end_address || "")}</small></span>
|
||
<span class="v">${t.distance_km != null ? de(t.distance_km) + " km" : "offen"}<small>${t.status}</small></span>
|
||
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
|
||
${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
|
||
<span class="k">Letzter Tankvorgang<small>${esc(f.station_name || "")}</small></span>
|
||
<span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : ""}</small></span>
|
||
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}</div>`;
|
||
}
|
||
|
||
function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; }
|
||
|
||
function intervalle(t) {
|
||
const oel = CAR.oel, b = t.basis;
|
||
if (!b) return null;
|
||
const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) });
|
||
if (t.art === "Hauptuntersuchung") {
|
||
const eig = bauen(null, 24);
|
||
return { hersteller: null, eigen: eig, prognose: eig.datum };
|
||
}
|
||
const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000;
|
||
const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24;
|
||
const eig = bauen(eKm, eM);
|
||
// "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten"
|
||
// tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist
|
||
// "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten
|
||
// immer die Herstellerwerte zeigen und das Kundenintervall ignorieren.
|
||
const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24);
|
||
const perKm = prognose(eig.km).datum;
|
||
return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum };
|
||
}
|
||
/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe
|
||
einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete
|
||
Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht:
|
||
die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den
|
||
selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu
|
||
nichts meldet (Sensor fehlt/nicht verfügbar). */
|
||
function fahrzeugMeldungRoh(art) {
|
||
if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm };
|
||
if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm };
|
||
return { ts: null, km: null };
|
||
}
|
||
|
||
/* App-eigene Ölwechsel-Prognose, eigenständig statt über die allgemeine
|
||
tagesleistung()/prognose()-Mischrechnung (die für Ölwechsel zu träge ist,
|
||
weil sie den langfristigen Durchschnitt seit dem allerersten Servicebuch-
|
||
eintrag mit einfließen lässt): die Berechnung beginnt bewusst komplett neu
|
||
ab dem Datum des letzten tatsächlich durchgeführten Ölwechsels.
|
||
|
||
Logik (wie vorgegeben): km und Tage seit dem letzten Ölwechsel ergeben die
|
||
aktuelle Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum
|
||
eingestellten Intervall (Kundeneigene Einstellung, siehe CAR.oel) ergibt
|
||
das ein Datum - gedeckelt auf spätestens das Intervall-Zeitlimit (z. B. 1
|
||
Jahr) nach dem letzten Ölwechsel, falls seither wenig gefahren wurde. */
|
||
function oelwechselPrognose() {
|
||
if (!CAR.odoBekannt) return null;
|
||
const letzter = letzterEintrag("öl");
|
||
if (!letzter) return null;
|
||
const oel = CAR.oel;
|
||
const intervallKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm);
|
||
const intervallMonate = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate);
|
||
const zielKm = letzter.km + intervallKm;
|
||
const restKm = Math.max(0, zielKm - CAR.odo);
|
||
const kappDatum = monatePlus(letzter.datum, intervallMonate);
|
||
let datum;
|
||
if (restKm <= 0) {
|
||
datum = heute();
|
||
} else {
|
||
const tageSeit = (heute() - dat(letzter.datum)) / dTag;
|
||
const kmSeit = CAR.odo - letzter.km;
|
||
if (tageSeit <= 0 || kmSeit <= 0) {
|
||
// Noch keine belastbare Fahrleistung seit dem Ölwechsel bekannt (z. B.
|
||
// direkt danach) - ohne Rate keine seriöse Km-Prognose, also das
|
||
// Zeitlimit als einzig belastbare Grenze zeigen.
|
||
datum = kappDatum;
|
||
} else {
|
||
const kmProTagSeitOel = kmSeit / tageSeit;
|
||
const tageBisZiel = restKm / kmProTagSeitOel;
|
||
const berechnet = new Date(heute().getTime() + tageBisZiel * dTag);
|
||
datum = berechnet < kappDatum ? berechnet : kappDatum;
|
||
}
|
||
}
|
||
return { km: zielKm, restKm, datum };
|
||
}
|
||
|
||
function terminText(t) {
|
||
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
|
||
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
|
||
const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum);
|
||
const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum)
|
||
: (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch fortgeschrieben"));
|
||
return { haupt, neben, restKm };
|
||
}
|
||
|
||
/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus-
|
||
Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet
|
||
dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24,
|
||
dann Tage. */
|
||
function standAlterText() {
|
||
if (LETZTES_UPDATE_TS == null) return "unbekannt";
|
||
const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000));
|
||
if (sekunden < 60) return `vor ${sekunden} Sek.`;
|
||
const minuten = Math.floor(sekunden / 60);
|
||
if (minuten < 60) return `vor ${minuten} Min.`;
|
||
const stunden = Math.floor(minuten / 60);
|
||
if (stunden < 24) return `vor ${stunden} Std.`;
|
||
return `vor ${Math.floor(stunden / 24)} Tage`;
|
||
}
|
||
function standAlterTicken() {
|
||
if (!ROOT || !CAR) return;
|
||
const sy = $("#sync span"); if (sy) sy.textContent = standAlterText();
|
||
}
|
||
|
||
function vHome() {
|
||
const g = CAR.gesichert;
|
||
const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt";
|
||
const statusDot = g === null ? "warn" : g ? "" : "bad";
|
||
const pct = CAR.tankPct;
|
||
const balkenFarbe = pct <= 10 ? "var(--bad)" : pct <= 25 ? "var(--warn)" : "var(--ok)";
|
||
const liter = CONFIG.tankvolumen ? Math.round((CONFIG.tankvolumen * pct) / 100) : null;
|
||
const t = naechsterTermin();
|
||
/* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und
|
||
Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die
|
||
Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst
|
||
nicht kann. */
|
||
return `
|
||
<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:${balkenFarbe}"></i></div>
|
||
<span class="label" style="margin-top:12px">Reichweite${CAR.tankBekannt && liter != null ? ` · noch etwa ${de(liter)} l im Tank` : ""}</span>
|
||
</div>
|
||
<div class="duo">
|
||
<div class="tile"><span class="label">Kilometerstand</span>
|
||
<div class="fig" style="font-size:29px;margin-top:14px">${CAR.odoBekannt ? de(CAR.odo) : "–"}<span class="unit">km</span></div></div>
|
||
${(() => { if (!t) return `<div class="tile"><span class="label">Service</span><div class="wert" style="margin-top:14px">kein Eintrag</div></div>`;
|
||
const artLabel = t.art === "Hauptuntersuchung" ? "Haupt-<br>untersuchung" : esc(t.art);
|
||
if (t.art === "Ölwechsel") {
|
||
const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null;
|
||
const datum = t.kmDatum || t.datum;
|
||
const teile = [];
|
||
if (restKm !== null) teile.push(de(restKm / 1000, 1) + " tkm");
|
||
if (datum) teile.push(mmjj(datum));
|
||
return `<div class="tile"><span class="label">${artLabel}</span>
|
||
<div class="fig" style="font-size:29px;margin-top:14px">${teile.join("/ ")}</div></div>`;
|
||
}
|
||
const teileHU = [];
|
||
if (t.zuerst === "km") {
|
||
teileHU.push(de(t.km - CAR.odo) + " km");
|
||
if (t.kmDatum) teileHU.push(mmjj(t.kmDatum));
|
||
} else {
|
||
teileHU.push(mmjj(t.datum));
|
||
if (t.km !== null && CAR.odoBekannt) teileHU.push(de(Math.abs(t.km - CAR.odo)) + " km");
|
||
}
|
||
return `<div class="tile"><span class="label">${artLabel}</span>
|
||
<div class="fig" style="font-size:29px;margin-top:14px">${teileHU.join("/ ")}</div></div>`; })()}
|
||
</div>
|
||
${standortKachel()}
|
||
${teaser()}`;
|
||
}
|
||
|
||
function vSicherheit() {
|
||
const eintraege = CAR.sicherheitscheck;
|
||
const g = CAR.gesichert;
|
||
const offen = eintraege.filter((e) => e.ok === false).length;
|
||
const unbekannt = eintraege.filter((e) => e.ok === null).length;
|
||
const sammelText = g === null
|
||
? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert`
|
||
: g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`;
|
||
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe
|
||
Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
|
||
return `
|
||
<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">
|
||
<span class="dot ${g === null ? "warn" : g ? "" : "bad"}"></span>
|
||
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
|
||
</div>
|
||
<div class="tile"><span class="label">Geprüfte Punkte</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 ? "in Ordnung" : "prüfen"}
|
||
</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>${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l<small>${CAR.tankPct} % von ${CONFIG.tankvolumen} l</small></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);
|
||
if (fm.ts == null && fm.km == null) {
|
||
const x = terminText(t);
|
||
return `<div class="row" ${letzte}>
|
||
<dt>${t.art}</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" : "";
|
||
// Zusätzlich zur realen Meldung bei Ölwechsel klein die App-eigene
|
||
// Prognose (Servicebuch + tatsächliche Fahrleistung seit dem letzten
|
||
// Wechsel, siehe oelwechselPrognose()) - bei Inspektion bewusst nicht,
|
||
// wie gefordert. 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.
|
||
const eigen = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
|
||
return `<div class="row" ${letzte}>
|
||
<dt>${t.art}${eigen ? `<br><span style="color:var(--fg3);font-size:11px">${de(eigen.restKm)} km, voraussichtlich am ${dedat(eigen.datum)}</span>` : ""}</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>${esc(CAR.steuer.zeitraum)}</small></dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Zusammen</dt>
|
||
<dd>${eur(CAR.versicherung.beitrag + CAR.steuer.betrag)} €<small>feste Kosten im Jahr</small></dd></div>
|
||
</dl></button>
|
||
<button class="tile tilebtn" data-go="reifen:x">
|
||
<span class="label">Reifen · ${r.aktiv} montiert</span>
|
||
<svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
|
||
<dl class="rows" style="margin-top:10px">
|
||
<div class="row"><dt>${esc(a.marke)} ${esc(a.modell)}</dt><dd>${esc(a.mass)}</dd></div>
|
||
<div class="row"><dt>Laufleistung</dt><dd>${a.km != null ? de(a.km) + " km" : "unbekannt"}</dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Wechseltermin</dt>
|
||
<dd>${dedat(new Date(r.wechsel.datum))}<small>${r.wechsel.vereinbart ? "vereinbart" : "offen"}</small></dd></div>
|
||
</dl></button>`;
|
||
}
|
||
|
||
/* ------------------------------------------------- Batteriespannungs-Verlauf
|
||
Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt,
|
||
siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per
|
||
Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt.
|
||
bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit
|
||
Zoom/Pan flüssig bleiben. */
|
||
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null;
|
||
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
|
||
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
|
||
let bvTapKandidat = null, bvBewegtSeit = false;
|
||
const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22;
|
||
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||
|
||
/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei
|
||
aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle
|
||
AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V,
|
||
50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch
|
||
entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V -
|
||
deutlich über dem Ruhespannungs-Maximum.
|
||
|
||
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
|
||
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
|
||
Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet,
|
||
und zwar auf den Tagesminimalwert: der fällt typischerweise auf den
|
||
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
|
||
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
|
||
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
|
||
tauchen entsprechend nicht in der Ladezustands-Statistik auf. */
|
||
const AGM_RUHE_MAX_V = 13.2;
|
||
const BV_SOC_TABELLE = [
|
||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||
];
|
||
function bvSocProzent(v) {
|
||
const t = BV_SOC_TABELLE;
|
||
if (v <= t[0][0]) return 0;
|
||
if (v >= t[t.length - 1][0]) return 100;
|
||
for (let i = 0; i < t.length - 1; i++) {
|
||
const [v0, s0] = t[i], [v1, s1] = t[i + 1];
|
||
if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0));
|
||
}
|
||
return 100;
|
||
}
|
||
function bvSocLabel(soc) {
|
||
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch";
|
||
}
|
||
// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei
|
||
// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) -
|
||
// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok),
|
||
// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden.
|
||
function bvHexZuRgb(hex) {
|
||
const h = hex.trim().replace("#", "");
|
||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||
}
|
||
function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); }
|
||
function bvFarbstufen() {
|
||
const stil = getComputedStyle(HOST);
|
||
return {
|
||
bad: bvHexZuRgb(stil.getPropertyValue("--bad")),
|
||
warn: bvHexZuRgb(stil.getPropertyValue("--warn")),
|
||
ok: bvHexZuRgb(stil.getPropertyValue("--ok")),
|
||
};
|
||
}
|
||
function bvFarbe(v, stufen) {
|
||
let rgb;
|
||
if (v <= 11.6) rgb = stufen.bad;
|
||
else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6));
|
||
else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2));
|
||
else rgb = stufen.ok;
|
||
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
}
|
||
|
||
/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine
|
||
Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe
|
||
Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of-
|
||
health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt,
|
||
nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase
|
||
der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen
|
||
eingeordnet (übliche Lebensdauer 4-6 Jahre). */
|
||
function bvSohBewertung() {
|
||
const TAG_MS = 86400000;
|
||
if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." };
|
||
const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS;
|
||
if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." };
|
||
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS;
|
||
const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs);
|
||
const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs);
|
||
const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length;
|
||
const delta = neuD - altD;
|
||
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad";
|
||
const text = delta >= -0.05
|
||
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
|
||
: delta >= -0.15
|
||
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
|
||
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten.";
|
||
return { status, text, delta };
|
||
}
|
||
|
||
function vBatterieverlauf() {
|
||
// Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem
|
||
// Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der
|
||
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
|
||
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann.
|
||
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V)
|
||
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum }))
|
||
.sort((a, b) => a.t - b.t);
|
||
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
|
||
|
||
const genug = bvPunkte.length >= 2;
|
||
bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null;
|
||
bvDomain = genug ? [...bvVollDomain] : null;
|
||
|
||
const stufen = bvFarbstufen();
|
||
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
|
||
const soc = letzter ? bvSocProzent(letzter.v) : null;
|
||
const soh = bvSohBewertung();
|
||
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
|
||
const leerText = BATTERIEVERLAUF.length === 0
|
||
? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist."
|
||
: "Noch zu wenige Messwerte für einen Verlauf.";
|
||
|
||
return `
|
||
<div class="tile">
|
||
<span class="label">Verlauf</span>
|
||
${genug ? `
|
||
<div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
|
||
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;height:170px;display:block"></svg>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
|
||
<span class="label" id="bvRange" style="margin-top:0"></span>
|
||
<button class="aktion" data-bvreset style="padding:8px 16px;font-size:11px;width:auto;margin-top:0">Zoom zurücksetzen</button>
|
||
</div>
|
||
<div id="bvTooltip" style="display:none;margin-top:12px;font-size:13px;color:var(--fg2)"></div>
|
||
<span class="label" style="margin-top:16px">Ziehen zum Verschieben, mit zwei Fingern oder dem Mausrad
|
||
zoomen, auf einen Punkt tippen für Datum, Uhrzeit sowie Min-/Max-Spannung</span>
|
||
` : `
|
||
<span class="label" style="margin-top:14px;color:var(--fg3)">${leerText}</span>
|
||
`}
|
||
<span class="label" style="margin-top:16px">Farbskala Ladezustand (AGM, Ruhespannung)</span>
|
||
<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>
|
||
<span class="label" style="margin-top:12px">Näherung auf Basis der täglich niedrigsten gemessenen Spannung (aussagekräftig für die
|
||
Entladung), keine Kapazitätsmessung - Tageswerte über ${de(AGM_RUHE_MAX_V, 1)} V (Ladevorgang während der Fahrt) fließen nicht ein.
|
||
Für eine verlässliche Aussage ist ein Lasttest an der Batterie nötig.</span>
|
||
</div>`;
|
||
}
|
||
|
||
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); }
|
||
function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); }
|
||
function bvDomainKlemmen(domain) {
|
||
let [a, b] = domain;
|
||
const volle = bvVollDomain[1] - bvVollDomain[0];
|
||
let breite = Math.min(b - a, volle);
|
||
breite = Math.max(breite, volle * 0.02);
|
||
if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; }
|
||
if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; }
|
||
return [a, b];
|
||
}
|
||
function bvPlotRahmen(rect) {
|
||
return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) };
|
||
}
|
||
|
||
function bvZeichnen() {
|
||
const svg = $("#bvSvg");
|
||
if (!svg || !bvDomain) return;
|
||
const sichtbar = bvPunkte.filter((p) => p.t >= bvDomain[0] && p.t <= bvDomain[1]);
|
||
const basis = sichtbar.length >= 2 ? sichtbar : bvPunkte;
|
||
let yMin = Math.min(...basis.map((p) => p.v)), yMax = Math.max(...basis.map((p) => p.v));
|
||
if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; }
|
||
const pad = (yMax - yMin) * 0.15; yMin -= pad; yMax += pad;
|
||
|
||
// etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am
|
||
// Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt
|
||
// weiterläuft
|
||
const spanne = bvDomain[1] - bvDomain[0];
|
||
const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05);
|
||
const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" ");
|
||
|
||
const gitter = [0, 1, 2, 3].map((i) => {
|
||
const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax);
|
||
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${BV_W - BV_MR}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
|
||
<text x="${BV_ML - 6}" y="${(y + 3).toFixed(1)}" font-size="8" fill="var(--fg3)" text-anchor="end">${de(v, 1)}</text>`;
|
||
}).join("");
|
||
const xLabels = [0, 0.5, 1].map((f) => {
|
||
const x = BV_ML + f * (BV_W - BV_ML - BV_MR);
|
||
const t = bvDomain[0] + f * spanne;
|
||
return `<text x="${x.toFixed(1)}" y="${BV_H - 6}" font-size="8" fill="var(--fg3)"
|
||
text-anchor="${f === 0 ? "start" : f === 1 ? "end" : "middle"}">${kurzdat(new Date(t))}</text>`;
|
||
}).join("");
|
||
const stufen = bvFarbstufen();
|
||
const kreise = rahmen.map((p) => {
|
||
const idx = bvPunkte.indexOf(p);
|
||
const gewaehlt = idx === bvAusgewaehlt;
|
||
return `<circle cx="${bvSkalaX(p.t).toFixed(1)}" cy="${bvSkalaY(p.v, yMin, yMax).toFixed(1)}"
|
||
r="${gewaehlt ? 4.5 : 2.4}" fill="${bvFarbe(p.v, stufen)}"
|
||
${gewaehlt ? 'stroke="var(--fg)" stroke-width="1"' : ""}/>`;
|
||
}).join("");
|
||
|
||
svg.innerHTML = `${gitter}<polyline points="${linie}" fill="none" stroke="var(--fg)" stroke-width="1.5"/>${kreise}${xLabels}`;
|
||
|
||
const rangeEl = $("#bvRange");
|
||
if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`;
|
||
const tip = $("#bvTooltip");
|
||
if (tip) {
|
||
if (bvAusgewaehlt != null) {
|
||
const p = bvPunkte[bvAusgewaehlt];
|
||
const zeit = new Date(p.minTs);
|
||
tip.style.display = "block";
|
||
tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`;
|
||
} else {
|
||
tip.style.display = "none";
|
||
}
|
||
}
|
||
}
|
||
|
||
function bvNaechsterPunkt(clientX) {
|
||
const svg = $("#bvSvg"); if (!svg) return null;
|
||
const rect = svg.getBoundingClientRect();
|
||
const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]);
|
||
let beste = null, besterAbstand = Infinity;
|
||
bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } });
|
||
return beste;
|
||
}
|
||
|
||
let svAuswahl = 0;
|
||
function vService() {
|
||
const sv = CAR.service;
|
||
return `
|
||
<div class="tile"><span class="label">Anstehende Termine</span>
|
||
<dl class="rows" style="margin-top:10px">
|
||
${termine().map((t, i, arr) => {
|
||
const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : "";
|
||
const iv = intervalle(t);
|
||
if (!iv) return `<div class="row" ${letzte}>
|
||
<dt>${t.art}</dt><dd style="color:var(--fg3)">kein Eintrag im Servicebuch</dd></div>`;
|
||
const anzeige = iv.hersteller || iv.eigen;
|
||
const fm = fahrzeugMeldungRoh(t.art);
|
||
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
|
||
// Forecast-Zeile nur bei Ölwechsel (Kundeneigenes Intervall aus
|
||
// "Einrichten", eigenständige Reset-nach-Service-Logik, siehe
|
||
// oelwechselPrognose()) - bei Inspektion und Hauptuntersuchung
|
||
// bewusst keine Forecast-Zeile mehr.
|
||
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
|
||
return `<div class="row" ${letzte}>
|
||
<dt>${t.art}
|
||
${t.vereinbart ? `<br><span style="color:var(--red);font-size:11px">Werkstatt am ${dedat(new Date(t.vereinbart))}</span>` : ""}
|
||
</dt>
|
||
<dd>
|
||
${iv.hersteller ? `<span class="label" style="margin-bottom:6px">Herstellervorgabe</span>` : ""}
|
||
${fm.km != null || fmDatum ? `
|
||
<span class="werte">${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}</span>
|
||
${eigenOel ? `<small style="text-align:left">${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}</small>` : ""}
|
||
` : `
|
||
<span class="werte">${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)}</span>
|
||
`}
|
||
</dd>
|
||
</div>`; }).join("")}
|
||
</dl>
|
||
</div>
|
||
<div class="tile"><span class="label">Termin vereinbaren</span>
|
||
${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)];
|
||
const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : "");
|
||
return `
|
||
<div class="feld"><label for="svArt">Wofür</label>
|
||
<select id="svArt" style="width:132px" data-svsel>${ts.map((x, i) => `<option value="${i}" ${i === svAuswahl ? "selected" : ""}>${x.art}</option>`).join("")}</select></div>
|
||
<div class="feld" style="border-bottom:0"><label for="svDat">Werkstatttermin am</label>
|
||
<input id="svDat" type="date" style="width:132px" value="${vorschlag}" data-vereinbart="${esc(t.art)}"></div>
|
||
<button class="aktion" data-ics="service">In den Kalender übernehmen</button>
|
||
${CAR.service.vereinbart[t.art] ? `<button class="aktion" data-loeschen="${esc(t.art)}">Termin verwerfen</button>` : ""}`; })()}
|
||
</div>
|
||
<div class="tile">
|
||
<span class="label">Autohaus</span>
|
||
<button class="zahnrad" data-go="werkstatt:x" aria-label="Angaben ändern">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
|
||
</button>
|
||
<div style="font-size:15px;margin-top:12px">${esc(sv.autohaus.name)}</div>
|
||
<span class="label" style="margin-top:8px">${esc(sv.autohaus.adresse)}</span>
|
||
<a class="aktion" href="tel:${esc(sv.autohaus.tel.replace(/\s/g, ""))}"
|
||
style="display:block;text-decoration:none;text-align:center;margin-top:16px">${esc(sv.autohaus.tel)}</a>
|
||
${sv.autohaus.mail ? `<a class="aktion" href="mailto:${esc(sv.autohaus.mail)}"
|
||
style="display:block;text-decoration:none;text-align:center">E-Mail schreiben</a>` : ""}
|
||
<a class="aktion" target="_blank" rel="noopener"
|
||
href="https://maps.google.com/?q=${encodeURIComponent(sv.autohaus.adresse)}"
|
||
style="display:block;text-decoration:none;text-align:center">In Karten öffnen</a>
|
||
</div>
|
||
<div class="tile"><span class="label">Servicebuch</span>
|
||
${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, `<button class="leaf" style="padding-left:0" data-go="sbuch:${i}">
|
||
<span class="k">${esc(e.art)}<small>${esc(e.werkstatt)}</small></span>
|
||
<span class="v">${e.datum}<small>${de(e.km)} km${e.kosten != null ? " · " + eur(e.kosten) + " €" : ""}</small></span>
|
||
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`)).join("") : `<div class="leer" style="margin-top:12px">
|
||
<strong>Noch keine Einträge</strong>
|
||
<p>Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.</p>
|
||
</div>`}
|
||
<button class="aktion primaer" data-sbneu>Eintrag hinzufügen</button>
|
||
</div>`;
|
||
}
|
||
|
||
function vIdent() {
|
||
return `
|
||
<div class="tile"><span class="label">Fahrzeug</span>
|
||
<dl class="rows" style="margin-top:10px">
|
||
<div class="row"><dt>Modell</dt><dd>${esc(CONFIG.fahrzeugtitel)}</dd></div>
|
||
<div class="row"><dt>Ausführung</dt><dd>${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}</dd></div>
|
||
<div class="row"><dt>Fahrgestellnummer</dt><dd>${esc(CAR.fin)}</dd></div>
|
||
<div class="row"><dt>Motor und Leistung</dt><dd>${esc(CAR.details)}</dd></div>
|
||
<div class="row"><dt>Erstzulassung</dt><dd>${esc(CAR.erstzulassung)}</dd></div>
|
||
<div class="row"><dt>Kennzeichen</dt><dd>${esc(CONFIG.kennzeichen)}</dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Zulassung</dt><dd>Deutschland</dd></div>
|
||
</dl></div>
|
||
${CAR.technik.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
|
||
<dl class="rows" style="margin-top:8px">
|
||
${g.posten.map((pp, i) => `<div class="row" ${i === g.posten.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||
<dt>${esc(pp[0])}</dt>
|
||
<dd style="${pp[1] ? "" : "color:var(--fg3)"}">${pp[1] ? esc(pp[1]) : "noch offen"}</dd></div>`).join("")}
|
||
</dl></div>`).join("")}
|
||
${CAR.ausstattung.map((g) => `<div class="tile"><span class="label">${esc(g.gruppe)}</span>
|
||
<dl class="rows" style="margin-top:8px">
|
||
${g.sonder.map((x, i) => `<div class="row" ${i === g.sonder.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||
<dt style="color:var(--fg)">${esc(x)}</dt><dd></dd></div>`).join("")}
|
||
</dl></div>`).join("")}
|
||
<button class="aktion">Daten bearbeiten</button>`;
|
||
}
|
||
|
||
function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; }
|
||
function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; }
|
||
function termine() {
|
||
const oel = CAR.oel;
|
||
const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm);
|
||
const oelM = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate);
|
||
const bauen = (art, basis, kmIntervall, monate, ersatzDatum) => {
|
||
const kmZiel = basis && kmIntervall ? basis.km + kmIntervall : null;
|
||
const datZiel = basis ? monatePlus(basis.datum, monate) : (ersatzDatum ? monatePlus(ersatzDatum, monate) : null);
|
||
const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null;
|
||
const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit");
|
||
return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis,
|
||
ziel: zuerst === "km" ? kmDatum : datZiel, vereinbart: CAR.service.vereinbart[art] || null };
|
||
};
|
||
const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM);
|
||
// Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose())
|
||
// ersetzt hier den km-Anteil aus dem generischen bauen() oben, damit
|
||
// Übersicht, Service-Kachel und Anstehende Termine überall dieselbe Zahl
|
||
// für "wann ist der nächste Ölwechsel fällig" zeigen statt drei leicht
|
||
// unterschiedlicher Werte.
|
||
const oelProg = oelTermin.basis ? oelwechselPrognose() : null;
|
||
if (oelProg) {
|
||
oelTermin.km = oelProg.km;
|
||
oelTermin.kmDatum = oelProg.datum;
|
||
oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km";
|
||
oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum;
|
||
}
|
||
return [
|
||
oelTermin,
|
||
bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24),
|
||
bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
|
||
CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
|
||
];
|
||
}
|
||
|
||
function vWerkstatt() {
|
||
const a = CAR.service.autohaus;
|
||
return `
|
||
<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>
|
||
</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>
|
||
<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 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>${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>
|
||
<span style="display:block;margin-top:10px;color:var(--fg2);
|
||
font-size:13.5px;line-height:1.6">
|
||
Fahrgestellnummer, Kennzeichen, Erstzulassung, Ausführung und Ölwechsel-Intervall — bei der
|
||
Ersteinrichtung oder wenn sich diese Angaben ändern.</span>
|
||
${einrichtenOffen ? `
|
||
<div class="feld" style="margin-top:16px"><label for="einFin">Fahrgestellnummer (FIN)</label>
|
||
<input id="einFin" value="${esc(CAR.fin || "")}" data-einrichten="fin"></div>
|
||
<div class="feld"><label for="einModell">Modell</label>
|
||
<select id="einModell" data-einrichten="modell">
|
||
${(FAHRZEUG_MODELLE.includes(CONFIG.fahrzeugtitel) ? FAHRZEUG_MODELLE : [CONFIG.fahrzeugtitel, ...FAHRZEUG_MODELLE])
|
||
.map((m) => `<option ${m === CONFIG.fahrzeugtitel ? "selected" : ""}>${esc(m)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="feld"><label for="einKennzeichen">Kennzeichen</label>
|
||
<input id="einKennzeichen" value="${esc(CONFIG.kennzeichen || "")}" data-einrichten="kennzeichen"></div>
|
||
<div class="feld"><label for="einErstzulassung">Erstzulassung</label>
|
||
<input id="einErstzulassung" value="${esc(CAR.erstzulassung || "")}" data-einrichten="erstzulassung" placeholder="MM/JJJJ"></div>
|
||
<div class="feld" style="border-bottom:0"><label for="einAusfuehrung">Ausführung</label>
|
||
<input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div>
|
||
<span class="label" style="margin-top:20px">Ölwechsel-Intervall</span>
|
||
<div class="seg" style="margin-top:8px">
|
||
<button data-oel="hersteller" class="${CAR.oel.modus === "hersteller" ? "on" : ""}">Herstellervorgabe</button>
|
||
<button data-oel="eigen" class="${CAR.oel.modus === "eigen" ? "on" : ""}">Eigenes</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>
|
||
<span class="label" style="margin-top:14px">Aus dem Fahrzeug gelesen</span>`
|
||
: `<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 ${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>
|
||
<div style="margin-top:16px">${(() => { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix"); })()}</div>
|
||
</div>
|
||
<div class="tile"><span class="label">Fahrzeugbilder</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">
|
||
Auf ein Bild tippen, um ein eigenes Foto hochzuladen oder zu ersetzen.</span>
|
||
<div class="bildgrid">
|
||
${BILDER_UPLOAD_SLOTS.map((s) => `
|
||
<div class="bildslot">
|
||
<div data-bildklick="${esc(s.datei)}">
|
||
${bildMitPlatzhalter(`/local/bilder/${s.datei}?v=${bildVersion}`, s.datei, s.label, "carfix mini", null, true)}
|
||
</div>
|
||
<input type="file" accept="image/*" hidden data-bildupload="${esc(s.datei)}">
|
||
<span class="bildslot-label">${esc(s.name)}</span>
|
||
${bildMenuOffen === s.datei ? `
|
||
<div class="bildmenu-catcher" data-bildmenu-schliessen></div>
|
||
<div class="bildmenu slot">
|
||
<button type="button" data-bildersetzen="${esc(s.datei)}">Foto ersetzen</button>
|
||
<button type="button" class="loeschen" data-bildloeschen="${esc(s.datei)}">Löschen</button>
|
||
</div>` : ""}
|
||
</div>`).join("")}
|
||
</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="/local/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
|
||
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
|
||
${CAR.smartdeal.aktiv ? `
|
||
<div class="feld" style="border-bottom:0"><label>Gültig bis</label>
|
||
<span>${dedat(new Date(CAR.smartdeal.laeuftAb))}</span></div>` :
|
||
`<span class="label" style="margin-top:14px">Ersparnis und Bildmarke werden ausgeblendet</span>`}
|
||
${smartdealPopupOffen ? `
|
||
<div class="bildmenu-catcher" data-sdabbrechen></div>
|
||
<div class="sdpopup">
|
||
<div class="feld" style="border-bottom:0"><label for="sdPopupBis">Gültig bis</label>
|
||
<input id="sdPopupBis" type="date" style="width:132px" value="${CAR.smartdeal.laeuftAb || ""}"></div>
|
||
<div class="seg" style="margin-top:14px">
|
||
<button type="button" data-sdabbrechen>Abbrechen</button>
|
||
<button type="button" class="on" data-sdbestaetigen>Bestätigen</button>
|
||
</div>
|
||
</div>` : ""}
|
||
</div>
|
||
<div class="tile"><span class="label">Fahrten zusammenfassen</span>
|
||
<div class="feld" style="border-bottom:0"><label for="pause">Pause bis</label>
|
||
<span class="mitEinheit"><input id="pause" type="number" step="5" min="0"
|
||
value="${CONFIG.pauseMin}" data-pause><span>Minuten</span></span></div>
|
||
<span class="label" style="margin-top:14px">Steigst du kürzer als diese Zeit aus — etwa zum Tanken —,
|
||
bleibt es eine zusammenhängende Fahrt statt zweier</span>
|
||
</div>
|
||
<div class="tile"><span class="label">Fahrzeugprofil</span>
|
||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||
text-transform:none;font-size:12.5px;line-height:1.6">
|
||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
|
||
diese Datei getauscht — die App selbst bleibt unverändert.</span>
|
||
<button class="aktion" data-profil="export">Profil sichern</button>
|
||
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
||
Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
|
||
</div>
|
||
<div class="tile"><span class="label">Backup</span>
|
||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||
text-transform:none;font-size:12.5px;line-height:1.6">
|
||
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine
|
||
Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen
|
||
Gesamtstand als einzelne Datei.</span>
|
||
${CONFIG.letztesBackup ? `<span class="label" style="margin-top:14px">Letztes Backup:
|
||
${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr</span>` : ""}
|
||
<button class="aktion" data-backup="jetzt">Backup jetzt erstellen</button>
|
||
<div class="feld" style="margin-top:16px;border-bottom:0"><label for="backupIntervall">Autom. Backup</label>
|
||
<select id="backupIntervall" data-backupintervall>
|
||
${[["aus", "Aus"], ["taeglich", "Täglich"], ["woechentlich", "Wöchentlich"], ["monatlich", "Monatlich"]]
|
||
.map(([v, l]) => `<option value="${v}" ${v === CONFIG.backupIntervall ? "selected" : ""}>${l}</option>`).join("")}
|
||
</select></div>
|
||
<button class="aktion" data-backup="export" style="margin-top:16px">Backup exportieren</button>
|
||
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
||
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
|
||
</div>
|
||
<div class="tile"><span class="label">Daten ausgeben</span>
|
||
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
|
||
Tabellenprogrammen aufgehen.</span>
|
||
<button class="aktion" data-csv="fahrten">Fahrten als CSV</button>
|
||
<button class="aktion" data-csv="tanken">Tankvorgänge als CSV</button>
|
||
<button class="aktion" data-csv="service">Servicebuch als CSV</button>
|
||
</div>
|
||
<div class="tile"><span class="label">Version</span>
|
||
<dl class="rows">
|
||
<div class="row"><dt>Dashboard</dt><dd>${esc(CONFIG.version)}</dd></div>
|
||
<div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
|
||
</dl>
|
||
${(() => {
|
||
const s = UPDATE_STATUS;
|
||
if (!s || s.stand === "keine_quelle") return `
|
||
<span class="label" style="margin-top:14px">${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"}</span>
|
||
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Update suchen"}</button>`;
|
||
if (s.stand === "fehler") return `
|
||
<span class="label" style="margin-top:14px;color:var(--red)">${esc(s.fehler || "Update-Prüfung fehlgeschlagen")}</span>
|
||
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Erneut versuchen"}</button>`;
|
||
if (s.stand === "geprueft" && s.verfuegbar) return `
|
||
<span class="label" style="margin-top:14px">Update verfügbar — Version ${esc(s.neue_version)}</span>
|
||
<button class="aktion" data-update-installieren style="margin-top:12px">${updateInstallierend ? "Installiere …" : "Update installieren"}</button>`;
|
||
return `
|
||
<span class="label" style="margin-top:14px">Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""}</span>
|
||
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Erneut prüfen"}</button>`;
|
||
})()}
|
||
</div>`;
|
||
}
|
||
|
||
let nmOffen = false;
|
||
let einrichtenOffen = false;
|
||
let bildMenuOffen = null;
|
||
let smartdealPopupOffen = false;
|
||
|
||
/* --------------------------------------------------------- Setup-Popup
|
||
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
|
||
Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern"
|
||
an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */
|
||
let setupOffen = false;
|
||
let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder
|
||
let setupZuordnungStart = null; // Schnappschuss von setupZuordnung beim Öffnen - für den Neustart-Hinweis (welche trigger-gebundenen Felder haben sich geändert)
|
||
let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken
|
||
let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null
|
||
let setupSuchtext = "";
|
||
let setupSpeichertLaeuft = false;
|
||
let ALLE_ENTITAETEN = [];
|
||
|
||
function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; }
|
||
|
||
function alleEntitaeten() {
|
||
if (!HASS || !HASS.states) return [];
|
||
return Object.keys(HASS.states).map((id) => {
|
||
const attrs = (HASS.states[id] && HASS.states[id].attributes) || {};
|
||
return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" };
|
||
});
|
||
}
|
||
|
||
function entitaetName(id) {
|
||
if (!id) return "";
|
||
const treffer = ALLE_ENTITAETEN.find((e) => e.id === id);
|
||
return treffer ? treffer.name : id;
|
||
}
|
||
|
||
/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten,
|
||
Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der
|
||
Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart,
|
||
siehe entitaetKandidaten()). */
|
||
function entitaetScore(feld, e, suchtext) {
|
||
let score = 0;
|
||
const hay = (e.id + " " + e.name).toLowerCase();
|
||
for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5;
|
||
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
|
||
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
|
||
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
|
||
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
|
||
return score;
|
||
}
|
||
|
||
function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
|
||
const feld = setupKatalog().find((f) => f.key === feldKey);
|
||
if (!feld) return [];
|
||
const text = (suchtext || "").trim().toLowerCase();
|
||
const liste = ALLE_ENTITAETEN.filter((e) => {
|
||
if (e.id === aktuellerWert) return true;
|
||
if (setupNurPassend) {
|
||
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
|
||
if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false;
|
||
}
|
||
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
|
||
return true;
|
||
});
|
||
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
|
||
return liste.slice(0, 60);
|
||
}
|
||
|
||
/* Ab diesem Wert gilt ein Vorschlag als belastbar. entitaetScore() vergibt für
|
||
einen reinen Domain-Treffer bereits 3 Punkte - eine Schwelle von 3 wäre
|
||
deshalb immer erfüllt und hätte jedes leere Feld mit dem erstbesten Sensor
|
||
der passenden Domain belegt (Reihenfolge aus Object.keys(HASS.states), also
|
||
willkürlich). 5 verlangt mindestens einen Stichworttreffer. */
|
||
const SETUP_VORSCHLAG_SCHWELLE = 5;
|
||
|
||
/* Bester Vorschlag für ein Feld, der noch nicht anderweitig vergeben ist.
|
||
Das "noch nicht vergeben" ist der Punkt: ohne diese Prüfung bekamen alle
|
||
vier Positionen einer Liste (Türen, Fenster, Schlösser) denselben Sensor,
|
||
weil der Vorschlag nicht vom Index abhängt. Der Sicherheitscheck hätte
|
||
danach viermal dieselbe Tür geprüft und "Sicher abgestellt" gemeldet,
|
||
obwohl drei Türen nie geprüft wurden. */
|
||
function setupVorschlag(feld, schonVergeben) {
|
||
for (const kandidat of entitaetKandidaten(feld.key, "", "")) {
|
||
if (schonVergeben.has(kandidat.id)) continue;
|
||
if (entitaetScore(feld, kandidat, "") < SETUP_VORSCHLAG_SCHWELLE) return "";
|
||
return kandidat.id;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function setupOeffnen() {
|
||
if (!setupBereit()) return;
|
||
ALLE_ENTITAETEN = alleEntitaeten();
|
||
setupNurPassend = true;
|
||
const werte = (ENTITAETEN && ENTITAETEN.werte) || {};
|
||
const zuordnung = {};
|
||
/* Bereits gesetzte und bereits vorgeschlagene IDs, damit kein Sensor
|
||
zweimal landet. Vorbelegte Werte kommen zuerst hinein. */
|
||
const vergeben = new Set();
|
||
for (const feld of setupKatalog()) {
|
||
const vorhandeneWerte = feld.liste ? (werte[feld.key] || []) : [werte[feld.key]];
|
||
for (const id of vorhandeneWerte) if (id) vergeben.add(id);
|
||
}
|
||
for (const feld of setupKatalog()) {
|
||
if (feld.liste) {
|
||
zuordnung[feld.key] = feld.positionen.map((_, i) => {
|
||
const vorhanden = (werte[feld.key] || [])[i];
|
||
if (vorhanden) return vorhanden;
|
||
const vorschlag = setupVorschlag(feld, vergeben);
|
||
if (vorschlag) vergeben.add(vorschlag);
|
||
return vorschlag;
|
||
});
|
||
} else {
|
||
const vorhanden = werte[feld.key];
|
||
if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; }
|
||
const vorschlag = setupVorschlag(feld, vergeben);
|
||
if (vorschlag) vergeben.add(vorschlag);
|
||
zuordnung[feld.key] = vorschlag;
|
||
}
|
||
}
|
||
setupZuordnung = zuordnung;
|
||
setupZuordnungStart = JSON.parse(JSON.stringify(zuordnung));
|
||
setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false;
|
||
setupOffen = true; render();
|
||
}
|
||
|
||
/* Das Setup lässt sich erst öffnen, wenn der Katalog wirklich da ist.
|
||
Ohne diese Prüfung öffnete sich das Fenster leer (setupKatalog() liefert
|
||
dann []), und ein Klick auf "Speichern" schrieb {} - was entitaeten.json
|
||
vollständig ersetzt und damit ALLE Zuordnungen löscht. Weil die Werte im
|
||
laufenden Prozess erhalten bleiben, wäre das erst beim nächsten Neustart
|
||
aufgefallen. */
|
||
function setupBereit() {
|
||
if (setupKatalog().length) return true;
|
||
hinweis("Setup noch nicht bereit",
|
||
"Die Liste der Sensor-Rollen wurde noch nicht vom Server geladen. Nach einem Neustart dauert das einen Moment - bitte gleich noch einmal versuchen.");
|
||
return false;
|
||
}
|
||
function setupSchliessen() {
|
||
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; render();
|
||
}
|
||
|
||
/* Standardwert (aus einstellungen.py, vor jedem Override) für ein Feld bzw.
|
||
eine Listenposition - Grundlage für den Zurücksetzen-Button. */
|
||
function setupStandardwert(key, idx) {
|
||
const standard = (ENTITAETEN && ENTITAETEN.standardwerte) || {};
|
||
const wert = standard[key];
|
||
if (idx == null) return wert || "";
|
||
return (wert || [])[idx] || "";
|
||
}
|
||
|
||
/* Entity-IDs, die im Arbeitsstand mehr als einer Rolle zugeordnet sind. */
|
||
function setupDuplikate() {
|
||
const zaehler = {};
|
||
for (const feld of setupKatalog()) {
|
||
const werte = feld.liste ? (setupZuordnung[feld.key] || []) : [setupZuordnung[feld.key]];
|
||
for (const id of werte) { if (id) zaehler[id] = (zaehler[id] || 0) + 1; }
|
||
}
|
||
return Object.keys(zaehler).filter((id) => zaehler[id] > 1);
|
||
}
|
||
|
||
/* Labels der trigger-gebundenen Felder (ZUENDUNG/KM/TANK), deren Wert sich
|
||
seit dem Öffnen des Popups geändert hat - für den Neustart-Hinweis nach
|
||
dem Speichern. */
|
||
function setupGeaenderteTriggerFelder() {
|
||
if (!setupZuordnungStart) return [];
|
||
return setupKatalog()
|
||
.filter((f) => f.trigger_gebunden && JSON.stringify(setupZuordnung[f.key]) !== JSON.stringify(setupZuordnungStart[f.key]))
|
||
.map((f) => f.label);
|
||
}
|
||
|
||
function setupSpeichernAusfuehren() {
|
||
// Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf
|
||
// irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben()
|
||
// ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles.
|
||
if (!setupZuordnung || !Object.keys(setupZuordnung).length) {
|
||
hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen.");
|
||
return;
|
||
}
|
||
setupSpeichertLaeuft = true; render();
|
||
(async () => {
|
||
try {
|
||
await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) });
|
||
} catch (err) {
|
||
setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return;
|
||
}
|
||
const geaendert = setupGeaenderteTriggerFelder();
|
||
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render();
|
||
if (geaendert.length) {
|
||
bestaetigen("Neustart nötig", `Wirkt für ${geaendert.join(", ")} erst nach einem Neustart von Home Assistant.`, "Jetzt neu starten", setupNeustartAusloesen, false);
|
||
}
|
||
})();
|
||
}
|
||
|
||
function setupNeustartAusloesen() {
|
||
(async () => {
|
||
try {
|
||
await HASS.callService("pyscript", "audi_dashboard_neustart", {});
|
||
hinweis("Neustart ausgelöst", "Home Assistant startet neu - die App lädt sich danach automatisch neu.");
|
||
} catch (err) {
|
||
hinweis("Neustart fehlgeschlagen", err.message);
|
||
}
|
||
})();
|
||
}
|
||
|
||
/* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert
|
||
liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in
|
||
der App zu bemerken. */
|
||
function entitaetStatusWarnung(wert) {
|
||
if (!wert) return "";
|
||
const zustand = HASS.states[wert] ? HASS.states[wert].state : null;
|
||
if (zustand === null) return `<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";
|
||
return `<div class="tile">
|
||
<div data-bildklick="${radDatei}">
|
||
${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")}
|
||
</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 class="radkopf">
|
||
<span class="label">${k === "sommer" ? "Sommer" : "Winter"}</span>
|
||
<div class="fig" style="font-size:32px;margin-top:14px">${t.km != null ? de(t.km) : "–"}<span class="unit">km</span></div>
|
||
</div>
|
||
<div class="seg" style="margin-top:16px">
|
||
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="${aktiv ? "on" : ""}">Montiert</button>
|
||
</div>
|
||
<div style="margin-top:22px">
|
||
${feld(k, "marke", "Hersteller")}
|
||
${feld(k, "modell", "Modell")}
|
||
${feld(k, "mass", "Größe")}
|
||
${feld(k, "dot", "DOT")}
|
||
<div class="feld"><label>Solldruck vorn / hinten</label>
|
||
<span class="mitEinheit">
|
||
<input class="druckfeld" value="${esc(t.druck_vorne ?? "")}" data-rf="${k}|druck_vorne" placeholder="–">
|
||
<span>/</span>
|
||
<input class="druckfeld" value="${esc(t.druck_hinten ?? "")}" data-rf="${k}|druck_hinten" placeholder="–">
|
||
<span>bar</span>
|
||
</span>
|
||
</div>
|
||
${feld(k, "kommentar", "Kommentar")}
|
||
</div>
|
||
</div>`;
|
||
};
|
||
return `
|
||
<div class="tile"><span class="label">Anzugsmoment der Radschrauben</span>
|
||
<button class="zahnrad" data-nmauf aria-label="Anzugsmoment ändern">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 2h4.2v2.84c.37.13.75.27 1.11.44L16.7 3.3l3 3.01-1.98 1.99c.17.36.31.74.44 1.11H21v4.3h-2.84c-.13.37-.27.75-.44 1.11l2 2-3.03 2.89-1.98-1.98c-.36.17-.74.31-1.11.44V21H9.4v-2.84a9 9 0 0 1-1.11-.44L6.3 19.7l-3-3.01 1.98-1.99a9 9 0 0 1-.44-1.11H2V9.4h2.84c.13-.37.27-.75.44-1.11L3.3 6.3l3.01-3L8.3 5.28c.36-.17.74-.31 1.11-.44V2Zm1 1v2.56l-.34.11c-.61.2-1.16.4-1.6.66l-.34.2L6.3 4.71 4.7 6.3l1.83 1.82-.2.34c-.27.44-.45.99-.66 1.6l-.11.34H3v2.2h2.56l.11.34c.2.61.4 1.16.66 1.6l.2.34-1.82 1.82 1.59 1.6 1.82-1.83.34.2c.44.27.99.45 1.6.66l.34.11V20h2.2v-2.56l.34-.11a8.3 8.3 0 0 0 1.6-.66l.34-.2 1.83 1.83 1.57-1.5-1.81-1.82.2-.34c.27-.44.45-.99.66-1.6l.11-.34H20v-2.3h-2.56l-.11-.34a8.2 8.2 0 0 0-.66-1.6l-.2-.34 1.82-1.82-1.59-1.6-1.82 1.83-.34-.2a8.2 8.2 0 0 0-1.6-.66l-.34-.11V3zm-2.9 8.5c0-2.18 1.82-4 4-4s4 1.82 4 4-1.82 4-4 4-4-1.82-4-4m4-3c-1.62 0-3 1.38-3 3s1.38 3 3 3 3-1.38 3-3-1.38-3-3-3"/></svg>
|
||
</button>
|
||
<div class="fig" style="font-size:34px;margin-top:12px">${de(r.anzugsmoment)}<span class="unit">Nm</span></div>
|
||
${nmOffen ? `
|
||
<div style="margin-top:18px;border-top:1px solid var(--line);padding-top:16px">
|
||
<div class="feld" style="border-bottom:0"><label for="nm">Neuer Wert</label>
|
||
<span class="mitEinheit"><input id="nm" type="number" step="5" min="0"
|
||
value="${r.anzugsmoment}"><span>Nm</span></span></div>
|
||
<button class="aktion" 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")}`;
|
||
}
|
||
|
||
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>
|
||
<div class="feld"><label>Datum</label><input type="date" id="neuFahrtDatum" value="${new Date().toISOString().slice(0, 10)}"></div>
|
||
<div class="feld"><label>Startzeit</label><input type="time" id="neuFahrtStart" value="08:00"></div>
|
||
<div class="feld" style="border-bottom:0"><label>Dauer in Minuten</label><input type="number" id="neuFahrtDauer" placeholder="0"></div>
|
||
<button class="aktion" data-fahrtspeichern>Speichern</button></div>` : ""}
|
||
<button class="aktion 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))} · ${esc(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>`;
|
||
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 ${t.art === "arbeitsweg" ? "work" : ""}" data-art="${t.trip_id}"
|
||
style="cursor:pointer;font-family:inherit">${esc(t.art)}</button></div>
|
||
<span class="label" style="margin-top:12px">tippen zum Umschalten</span>
|
||
</div>
|
||
<div class="tile"><dl class="rows">
|
||
<div class="row"><dt>Startort</dt><dd>${esc(t.start_address || "unbekannt")}<small>${dedat(new Date(t.ts_start))}</small></dd></div>
|
||
<div class="row"><dt>Zielort</dt><dd>${esc(t.end_address || "unbekannt")}</dd></div>
|
||
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
|
||
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
|
||
<div class="row"><dt>Dauer</dt><dd>${Math.round(t.duration_s / 60)} min</dd></div>
|
||
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
|
||
<div class="row"><dt>Status</dt><dd>${t.status}<small>Quelle: ${t.km_quelle || "–"}</small></dd></div>
|
||
</dl></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 kommt vom Fahrzeug (OBD) und wird
|
||
vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
|
||
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
|
||
/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */
|
||
function schnittFahrtVerbrauch(list) {
|
||
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
|
||
const km = sum(mit, (t) => t.distance_km);
|
||
return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–";
|
||
}
|
||
/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum
|
||
letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der
|
||
Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte
|
||
oder ein Tankvorgang noch unvollständig war. */
|
||
function langzeitverbrauch() {
|
||
const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
|
||
const letzter = tanks[tanks.length - 1];
|
||
const bis = new Date(letzter.ts);
|
||
const liter = sum(tanks, (f) => f.liters);
|
||
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
|
||
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
|
||
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
|
||
const mitOdo = tanks.filter((f) => f.odometer_km != null);
|
||
let km = 0, basis = "erfasste Fahrten";
|
||
if (mitOdo.length >= 2) {
|
||
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
|
||
basis = "Kilometerstand an den Tankvorgängen";
|
||
} else {
|
||
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
|
||
}
|
||
const roh = km > 0 ? (liter / km) * 100 : null;
|
||
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
|
||
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
|
||
const gueltig = roh != null && roh >= 3 && roh <= 30;
|
||
return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig };
|
||
}
|
||
function vStat() {
|
||
const jahr = new Date().getFullYear();
|
||
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
|
||
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
|
||
const tJahr = tankSeit("jahr");
|
||
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
|
||
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
|
||
const nachtKm = kmJahr - tagKm;
|
||
const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg");
|
||
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
|
||
const lv = langzeitverbrauch();
|
||
return `
|
||
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
|
||
quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
|
||
quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))}
|
||
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
|
||
quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
|
||
quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))}
|
||
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
|
||
`<dl class="rows">
|
||
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
|
||
<div class="row"><dt>Strecke bis zum letzten Tankstop</dt><dd>${de(lv.km)} km<small>${lv.basis}</small></dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
||
</dl>
|
||
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
|
||
${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
|
||
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.</span>`)}
|
||
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
|
||
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
|
||
<div class="legend"><span><i style="background:var(--fg)"></i>Tag · ${de(tagKm)} km</span>
|
||
<span><i style="background:var(--fg3)"></i>Nacht · ${de(nachtKm)} km</span></div>
|
||
<span class="label" style="margin-top:16px">Nacht = ${CONFIG.nachtVon} bis ${CONFIG.nachtBis} Uhr</span>`)}
|
||
${statTile("s5", "Art der Fahrten", kmJahr > 0 ? de(Math.round((arbeitKm / kmJahr) * 100)) : "–", "% Arbeitsweg", de(arbeitKm) + " km",
|
||
`<div class="bar"><i style="flex:${arbeitKm};background:var(--fg)"></i><i style="flex:${privatKm};background:var(--fg3)"></i></div>
|
||
<div class="legend"><span><i style="background:var(--fg)"></i>Arbeitsweg · ${de(arbeitKm)} km</span>
|
||
<span><i style="background:var(--fg3)"></i>Privat · ${de(privatKm)} km</span></div>
|
||
<dl class="rows" style="margin-top:18px">
|
||
<div class="row"><dt>Fahrten Arbeitsweg</dt><dd>${de(arbeitFahrten.length)}</dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Fahrten privat</dt><dd>${de(privatFahrten.length)}</dd></div></dl>`)}`;
|
||
}
|
||
|
||
let neuOffen = false;
|
||
let fahrtOffen = false;
|
||
let tankBearbeitenOffen = false;
|
||
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
|
||
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
|
||
let belegEntwurf = null;
|
||
// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"):
|
||
// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder
|
||
// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war.
|
||
let belegFehler = null;
|
||
// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher
|
||
// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine
|
||
// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null).
|
||
let belegZielPraefix = null;
|
||
|
||
/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog
|
||
zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit
|
||
einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über
|
||
Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events),
|
||
damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben
|
||
"art:id"-Schema wie data-go, siehe eintragLoeschen(). */
|
||
let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0;
|
||
let swipeWarGezogen = false;
|
||
const SWIPE_BREITE = 84;
|
||
|
||
/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des
|
||
Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene
|
||
Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig
|
||
voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer
|
||
Listenzeile UND ganz oben gescrollt). */
|
||
let ptrY0 = null, ptrDrag = 0, ptrLaedt = false;
|
||
async function datenAktualisieren() {
|
||
try {
|
||
await HASS.callService("pyscript", "audi_dashboard_jetzt_aktualisieren", {});
|
||
const states = await HASS.callWS({ type: "get_states" });
|
||
const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_fahrzeugstatus");
|
||
if (frisch) NACHGELADEN.status = frisch;
|
||
} catch (e) {
|
||
// bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt
|
||
// holt den Stand ohnehin nach
|
||
}
|
||
datenLaden(true);
|
||
}
|
||
function schliesseAlleSwipes() {
|
||
ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped"));
|
||
}
|
||
function swipeZeile(id, inhaltHtml) {
|
||
return `<div class="swipe-wrap" data-swipe="${id}">
|
||
<div class="swipe-delete"><button class="swipe-loeschen" data-swipe-loeschen="${id}">Löschen</button></div>
|
||
<div class="swipe-content">${inhaltHtml}</div>
|
||
</div>`;
|
||
}
|
||
const LOESCH_TEXT = {
|
||
trip: "Fahrt löschen?",
|
||
fill: "Tankvorgang löschen?",
|
||
sbuch: "Servicebuch-Eintrag löschen?",
|
||
bild: "Foto löschen?",
|
||
};
|
||
const LOESCH_HINWEIS = {
|
||
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
|
||
fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.",
|
||
sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.",
|
||
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
|
||
};
|
||
function eintragLoeschen(art, id) {
|
||
if (art === "trip") {
|
||
serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id });
|
||
TRIPS = TRIPS.filter((t) => t.trip_id !== id);
|
||
} else if (art === "fill") {
|
||
serviceRufen("audi_dashboard_tankvorgang_loeschen", { tank_id: id });
|
||
FILLS = FILLS.filter((f) => f.tank_id !== id);
|
||
} else if (art === "sbuch") {
|
||
CAR.service.buch.splice(parseInt(id, 10), 1);
|
||
profilSpeichern();
|
||
}
|
||
}
|
||
|
||
/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht
|
||
bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite
|
||
offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern
|
||
und den Verwerfen-Schritt in go(). */
|
||
let entwurfIndex = null;
|
||
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
|
||
|
||
/* Update-Funktion (Einstellungen -> "Update suchen", siehe
|
||
updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht
|
||
zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade-
|
||
Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt
|
||
passiv auf den nächsten hass-Push zu warten. */
|
||
let UPDATE_STATUS = null;
|
||
let UPDATE_STATUS_GEHOLT = false;
|
||
let updatePruefend = false;
|
||
let updateInstallierend = false;
|
||
async function updateStatusAbrufen() {
|
||
try {
|
||
const states = await HASS.callWS({ type: "get_states" });
|
||
const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status");
|
||
UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null;
|
||
} catch (e) {
|
||
// zeigt dann weiterhin den zuletzt bekannten Stand
|
||
}
|
||
}
|
||
|
||
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
|
||
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
|
||
Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details
|
||
und die technischen Daten hängen bewusst nicht daran - die bleiben, auch
|
||
bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen.
|
||
Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe
|
||
BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches
|
||
oder gar kein Badge auf der Übersicht. */
|
||
const FAHRZEUG_MODELLE = [
|
||
"RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant",
|
||
"RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance",
|
||
"RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7",
|
||
];
|
||
|
||
/* Badge-Zuordnung (www/badges/<datei>-positive.svg / -negative.svg, jeweils
|
||
das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der
|
||
FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der
|
||
Präfixe mit einem anderen überschneidet. */
|
||
const BADGE_DATEIEN = {
|
||
"RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7",
|
||
"S3": "s3", "SQ7": "sq7",
|
||
};
|
||
function badgePraefix() {
|
||
const titel = CONFIG.fahrzeugtitel || "";
|
||
return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null;
|
||
}
|
||
|
||
/* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines
|
||
bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen,
|
||
damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein
|
||
Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den
|
||
input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */
|
||
const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"];
|
||
|
||
/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum
|
||
zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel
|
||
wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py),
|
||
hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim
|
||
Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus
|
||
f.distance_km (siehe Aufrufer). */
|
||
function distanzVorschlag(aktuellerKm) {
|
||
if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return "";
|
||
const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
|
||
if (letzte.odometer_km == null) return "";
|
||
return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10;
|
||
}
|
||
|
||
function tankFelder(praefix, f) {
|
||
// Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf)
|
||
// überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen
|
||
// immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2).
|
||
const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null;
|
||
const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f;
|
||
const ts = q && q.ts ? new Date(q.ts) : new Date();
|
||
const datum = ts.toISOString().slice(0, 10);
|
||
const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0");
|
||
const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : "");
|
||
const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km);
|
||
const liter = q && q.liters != null ? q.liters : "";
|
||
const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : "";
|
||
const ersparnis = q && q.discount != null ? q.discount : "";
|
||
const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0];
|
||
const station = q && q.station_name ? q.station_name : "";
|
||
const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0
|
||
? `Preis je Liter: ${de(q.fuel_total_eur / q.liters, 3)} €` : "Preis je Liter: wird berechnet";
|
||
return `
|
||
<div class="feld"><label>Datum</label><input type="date" id="${praefix}Datum" value="${datum}"></div>
|
||
<div class="feld"><label>Uhrzeit</label><input type="time" id="${praefix}Zeit" value="${zeit}"></div>
|
||
<div class="feld"><label>Kilometerstand</label><input type="number" id="${praefix}Km" value="${km}"></div>
|
||
<div class="feld"><label>Gefahrene Distanz</label>
|
||
<span class="mitEinheit"><input type="number" step="1" id="${praefix}Distanz" value="${distanz}" placeholder="0"><span>km</span></span></div>
|
||
<div class="feld"><label>Kraftstoff</label>
|
||
<select id="${praefix}Kraftstoff">${KRAFTSTOFFSORTEN.map((k) => `<option ${k === kraftstoff ? "selected" : ""}>${k}</option>`).join("")}</select></div>
|
||
<div class="feld"><label>Getankte Liter</label><input type="number" step="0.01" id="${praefix}Liter" data-tankpreisfeld="${praefix}" value="${liter}" placeholder="0,00"></div>
|
||
<div class="feld"><label>Kosten (real, nach Rabatt)</label>
|
||
<span class="mitEinheit"><input type="number" step="0.01" id="${praefix}Kosten" data-tankpreisfeld="${praefix}" value="${kosten}" placeholder="0,00"><span>€</span></span></div>
|
||
${CAR.smartdeal.aktiv ? `<div class="feld"><label>SmartDeal-Ersparnis</label>
|
||
<span class="mitEinheit"><input type="number" step="0.01" id="${praefix}Ersparnis" value="${ersparnis}" placeholder="0,00"><span>€</span></span></div>` : ""}
|
||
<div class="feld" style="border-bottom:0"><label>Tankstelle</label><input id="${praefix}Station" value="${esc(station)}" placeholder="Name"></div>
|
||
<span class="label" style="margin-top:14px" id="${praefix}PreisAnzeige">${preisText}</span>`;
|
||
}
|
||
|
||
function tankFormularWerte(praefix) {
|
||
const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", "."));
|
||
const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", "."));
|
||
const kmFeld = $(`#${praefix}Km`).value;
|
||
const distanzFeld = $(`#${praefix}Distanz`).value;
|
||
const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null;
|
||
const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value;
|
||
return {
|
||
ts: new Date(`${datum}T${zeit}:00`).toISOString(),
|
||
liter, kosten,
|
||
km: kmFeld === "" ? null : parseFloat(kmFeld),
|
||
distanz: distanzFeld === "" ? null : parseFloat(distanzFeld),
|
||
ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null,
|
||
kraftstoff: $(`#${praefix}Kraftstoff`).value,
|
||
station: $(`#${praefix}Station`).value,
|
||
};
|
||
}
|
||
|
||
function vFuelFormular() {
|
||
// Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im
|
||
// Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind.
|
||
return `${neuOffen ? `<div class="tile"><span class="label">Neuer Tankvorgang</span>
|
||
${tankFelder("neuTank", null)}
|
||
${belegFehler && belegFehler.context === "neuTank" ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(belegFehler.meldung)}</span>` : ""}
|
||
<button class="aktion" data-beleg="neu">Beleg hochladen</button>
|
||
<button class="aktion primaer" data-tankspeichernbeleg>Speichern</button>
|
||
<button class="aktion" data-tankspeichern>Ohne Beleg speichern</button></div>` : ""}
|
||
<button class="aktion ${neuOffen ? "" : "primaer"}" data-neu>${neuOffen ? "Abbrechen" : "Tankvorgang erfassen"}</button>`;
|
||
}
|
||
function vFuel() {
|
||
const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a);
|
||
/* D5 */
|
||
if (!jahre.length) return `<div class="tile"><div class="leer">
|
||
<strong>Noch keine Tankvorgänge</strong>
|
||
<p>Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.</p>
|
||
</div></div>
|
||
${vFuelFormular()}`;
|
||
let h = `<div class="tile">`;
|
||
jahre.forEach((j, ji) => {
|
||
const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j);
|
||
const ersparnisJahr = sum(fj, (f) => f.discount || 0);
|
||
/* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge
|
||
folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher
|
||
konkurrierten drei gleich große rechtsbündige Werte miteinander. */
|
||
h += `<button class="acc-h" aria-expanded="${ji === 0}" data-acc="fy${j}"><span class="mark"></span>
|
||
<span class="k">${j}</span>
|
||
<span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||
<span style="font-size:17px;color:var(--fg)">${eur(sum(fj, (f) => f.fuel_total_eur || 0))} €</span>
|
||
<span>${de(sum(fj, (f) => f.liters || 0), 1)} l · Ø ${de(schnittpreis(fj), 3)} €/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), 3)} €/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, 3) + " €/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 `
|
||
${mapBox()}
|
||
<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, 3) + " €" : "–"}<small>Kosten ÷ Liter</small></dd></div>
|
||
${CAR.smartdeal.aktiv && f.discount != null ? `<div class="row"><dt><span class="marke">${markenlogo()} SmartDeal-Ersparnis</span></dt><dd>${eur(f.discount)} €</dd></div>` : ""}
|
||
<div class="row" style="border-bottom:0"><dt>Tankstelle</dt><dd>${esc(f.station_name || "unbekannt")}<small>${esc(f.station_address || "")}</small></dd></div>
|
||
</dl>
|
||
${belegFehler && belegFehler.context === f.tank_id ? `<span class="label" style="margin-top:14px;color:var(--red)">${esc(belegFehler.meldung)}</span>` : ""}
|
||
<button class="aktion" data-tankbearbeiten>Werte bearbeiten</button>
|
||
<button class="aktion" data-beleg="${f.tank_id}">${f.receipt_file ? "Beleg ersetzen" : "Beleg nachtragen"}</button>
|
||
<button class="aktion loeschen" data-filldel="${f.tank_id}">Tankvorgang löschen</button>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------------------------------------------------------- Steuerung */
|
||
const ICONS = {
|
||
home: '<path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/>',
|
||
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
|
||
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
|
||
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
|
||
trips: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5"/>',
|
||
stat: '<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2"/>',
|
||
fuel: '<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20"/><path d="M3 20h12.5"/><path d="M4.5 11h10.5"/><path d="M17 20v-8.5a2 2 0 0 0-2-2"/><path d="M17 11.5V7.8l-2-2.3"/>',
|
||
};
|
||
const TABS = [
|
||
{ id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" },
|
||
{ id: "audi", label: "Mein Audi", eyebrow: "", title: "" },
|
||
{ id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" },
|
||
{ id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" },
|
||
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
|
||
];
|
||
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
|
||
const ICONS_VOLL = new Set(["audi"]);
|
||
let route = { name: "home" };
|
||
const ZURUECK = {
|
||
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
|
||
vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails",
|
||
steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home",
|
||
battverlauf: "audi",
|
||
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
|
||
Zurück-Pfeil. */
|
||
sicherheit: "home",
|
||
standort: "home",
|
||
};
|
||
const ICON = {
|
||
sonne: '<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();
|
||
}
|
||
function sheetMarkup() {
|
||
if (!sheet) return "";
|
||
const kopf = `<div class="sheet-kopf"><strong>${esc(sheet.titel)}</strong>${sheet.text ? `<span>${esc(sheet.text)}</span>` : ""}</div>`;
|
||
const aktion = sheet.knopf
|
||
? `<button data-sheet-ok class="${sheet.destruktiv ? "loeschen" : ""}">${esc(sheet.knopf)}</button>`
|
||
: `<button data-sheet-abbrechen>Verstanden</button>`;
|
||
return `<div class="sheet-catcher" data-sheet-abbrechen></div>
|
||
<div class="sheet" role="dialog" aria-modal="true">
|
||
<div class="sheet-gruppe">${kopf}${aktion}</div>
|
||
${sheet.knopf ? `<button class="sheet-abbrechen" data-sheet-abbrechen>Abbrechen</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ Setup-Popup */
|
||
const LUPE_SVG = `<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="6"/><path d="m17 17-4.3-4.3"/></svg>`;
|
||
|
||
function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) {
|
||
const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert);
|
||
if (!kandidaten.length) return `<div class="entitaet-leer">Keine passende Entität gefunden</div>`;
|
||
return kandidaten.map((e) => {
|
||
const zustand = HASS.states[e.id] ? HASS.states[e.id].state : null;
|
||
const unbekannt = zustand === null || zustand === "unavailable" || zustand === "unknown";
|
||
const wertAnzeige = unbekannt ? (zustand === null ? "nicht gefunden" : zustand) : `${zustand}${e.unit ? " " + e.unit : ""}`;
|
||
return `
|
||
<button type="button" class="entitaet-eintrag ${e.id === aktuellerWert ? "aktiv" : ""}" data-setup-wahl="${esc(feldId)}" data-setup-entity="${esc(e.id)}">
|
||
<span class="entitaet-eintrag-name">${esc(e.name)}</span>
|
||
<span class="entitaet-eintrag-id">${esc(e.id)}</span>
|
||
<span class="entitaet-eintrag-wert ${unbekannt ? "unbekannt" : ""}">${esc(wertAnzeige)}</span>
|
||
</button>`;
|
||
}).join("");
|
||
}
|
||
|
||
const RESET_SVG = `<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M16 10a6 6 0 1 1-2-4.5"/><path d="M16 3v4h-4"/></svg>`;
|
||
|
||
function entitaetCombo(key, idx) {
|
||
const feldId = idx == null ? key : `${key}:${idx}`;
|
||
const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx];
|
||
const offen = setupSucheOffen === feldId;
|
||
const anzeige = offen ? setupSuchtext : entitaetName(wert);
|
||
const standard = setupStandardwert(key, idx);
|
||
const zeigeReset = wert !== standard;
|
||
return `<div class="entitaet-combo">
|
||
<span class="entitaet-combo-icon">${LUPE_SVG}</span>
|
||
<input type="text" class="entitaet-combo-input" autocomplete="off" spellcheck="false"
|
||
placeholder="Entität suchen …" value="${esc(anzeige)}" data-setup-suche="${esc(feldId)}">
|
||
${zeigeReset ? `<button type="button" class="entitaet-combo-reset" title="Auf Standard zurücksetzen" data-setup-reset="${esc(feldId)}">${RESET_SVG}</button>` : ""}
|
||
${offen ? `<div class="entitaet-liste" data-setup-liste="${esc(feldId)}">${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function setupFeldzeile(feld) {
|
||
const kopf = `<div class="setup-feld-kopf">
|
||
<span class="setup-feld-label">${esc(feld.label)}</span>
|
||
<span class="setup-feld-hinweis">${esc(feld.hinweis)}</span>
|
||
${feld.trigger_gebunden ? `<span class="setup-feld-neustart">Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.</span>` : ""}
|
||
</div>`;
|
||
if (feld.liste) {
|
||
return `<div class="setup-feldzeile">${kopf}
|
||
<div class="setup-unterfelder">
|
||
${feld.positionen.map((pos, i) => `
|
||
<div class="setup-unterfeld">
|
||
<span class="setup-unterfeld-label">${esc(pos)}</span>
|
||
${entitaetCombo(feld.key, i)}
|
||
${entitaetStatusWarnung((setupZuordnung[feld.key] || [])[i])}
|
||
</div>`).join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `<div class="setup-feldzeile">${kopf}${entitaetCombo(feld.key, null)}${entitaetStatusWarnung(setupZuordnung[feld.key])}</div>`;
|
||
}
|
||
|
||
const SETUP_GRUPPEN = [
|
||
["fahrterkennung", "Für die Fahrterkennung nötig"],
|
||
["uebersicht", "Übersicht & Status"],
|
||
["sicherheit", "Sicherheit"],
|
||
["standort", "Standort"],
|
||
];
|
||
|
||
function vSetupPopup() {
|
||
if (!setupOffen) return "";
|
||
const katalog = setupKatalog();
|
||
return `<div class="setup-catcher" data-setup-abbrechen></div>
|
||
<div class="setup-popup" role="dialog" aria-modal="true" aria-label="Setup">
|
||
<div class="setup-kopf">
|
||
<strong>Setup</strong>
|
||
<span>Sensoren dieser Home-Assistant-Instanz den App-Funktionen zuordnen.</span>
|
||
<div class="setup-kopf-schalter">
|
||
<label class="switch"><input type="checkbox" data-setup-nurpassend aria-label="Nur passende Sensoren anzeigen" ${setupNurPassend ? "checked" : ""}><span class="track"></span></label>
|
||
<span aria-hidden="true">Nur passende Sensoren anzeigen</span>
|
||
</div>
|
||
</div>
|
||
<div class="setup-koerper">
|
||
${SETUP_GRUPPEN.map(([key, titel]) => {
|
||
const felder = katalog.filter((f) => f.gruppe === key);
|
||
if (!felder.length) return "";
|
||
return `<div class="setup-gruppe">
|
||
<div class="setup-gruppe-titel">${esc(titel)}</div>
|
||
${felder.map((feld) => setupFeldzeile(feld)).join("")}
|
||
</div>`;
|
||
}).join("")}
|
||
</div>
|
||
<div class="setup-fuss">
|
||
<button type="button" class="aktion" data-setup-abbrechen>Abbrechen</button>
|
||
<button type="button" class="aktion primaer" data-setup-speichern>${setupSpeichertLaeuft ? "Speichert …" : "Speichern"}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
let letzteAnsicht = null;
|
||
let routeStack = [];
|
||
let navRichtung = null;
|
||
const istTab = (name) => TABS.some((t) => t.id === name);
|
||
/* Zu welchem Tab gehoert eine Unterseite? Erst der tatsaechlich gelaufene Weg
|
||
(routeStack), sonst die ZURUECK-Tabelle. */
|
||
function tabVon(name) {
|
||
if (routeStack.length && istTab(routeStack[0].name)) return routeStack[0].name;
|
||
let n = name, i = 0;
|
||
while (n && !istTab(n) && i++ < 8) n = ZURUECK[n];
|
||
return istTab(n) ? n : null;
|
||
}
|
||
function render() {
|
||
if (!DATEN_GELADEN) return;
|
||
// Eine laufende Wischgeste bezieht sich auf Knoten, die render() gleich
|
||
// durch neue ersetzt - Referenz kappen, bevor sie ins Leere zeigt.
|
||
swipeAktivWrap = null; swipeGeste = null; swipeDX = 0;
|
||
const v = $("#view"), b = $("#back");
|
||
const merkeScroll = v.scrollTop;
|
||
const gleicheAnsicht = letzteAnsicht === route.name + "|" + (route.id || "");
|
||
let head;
|
||
if (route.name === "home") { head = ["", "Übersicht"]; v.innerHTML = vHome(); }
|
||
else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); }
|
||
else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); }
|
||
else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); }
|
||
else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); }
|
||
else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); }
|
||
else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); }
|
||
else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); }
|
||
else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); }
|
||
else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); }
|
||
else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); }
|
||
else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); }
|
||
else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); }
|
||
else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); }
|
||
else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); }
|
||
else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); }
|
||
else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); }
|
||
else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); }
|
||
else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title];
|
||
v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); }
|
||
$("#eyebrow").textContent = head[0]; $("#title").textContent = head[1];
|
||
/* B3: Push nach rechts hinein, Pop nach links heraus. */
|
||
v.classList.remove("push-rein", "push-raus");
|
||
if (navRichtung && !gleicheAnsicht) {
|
||
void v.offsetWidth;
|
||
v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein");
|
||
}
|
||
navRichtung = null;
|
||
// .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays -
|
||
// die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop
|
||
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
|
||
// auf 0 zurueck).
|
||
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
|
||
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup();
|
||
if (setupOffen) {
|
||
const koerper = ROOT.querySelector(".setup-koerper");
|
||
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
|
||
}
|
||
// Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element
|
||
// (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst
|
||
// müsste man nach jedem Tippen erneut anklicken.
|
||
if (setupOffen && setupSucheOffen) {
|
||
const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`);
|
||
if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); }
|
||
}
|
||
/* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht
|
||
dort der Zurück-Pfeil. */
|
||
ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none";
|
||
const sy = $("#sync");
|
||
sy.style.display = route.name === "home" ? "flex" : "none";
|
||
sy.querySelector("span").textContent = standAlterText();
|
||
b.classList.toggle("on", !istTab(route.name));
|
||
v.scrollTop = gleicheAnsicht ? merkeScroll : 0;
|
||
letzteAnsicht = route.name + "|" + (route.id || "");
|
||
/* Die Karten haengen an DOM-Knoten, die render() ueber v.innerHTML gerade
|
||
ersetzt hat - eine ueberlebende Leaflet-Instanz zeigte danach auf ein
|
||
abgehaengtes Element und bliebe leer. Sie muessen deshalb bei jedem
|
||
Durchlauf neu aufgebaut werden; die Haeufigkeit begrenzt stattdessen die
|
||
Aenderungspruefung in datenLaden(). */
|
||
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
||
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
|
||
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
|
||
if (route.name === "standort") initStandortMap().catch(() => {});
|
||
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
|
||
if (route.name === "home") initVorschauMap().catch(() => {});
|
||
/* Menueleiste wie in der myAudi-App: genau ein Eintrag traegt die gefuellte
|
||
Pille, und zwar auch dann, wenn eine Unterseite offen ist - die gehoert
|
||
zum Tab, aus dem sie aufgerufen wurde (ZURUECK zeigt darauf). */
|
||
const aktiverTab = istTab(route.name) ? route.name : tabVon(route.name);
|
||
ROOT.querySelectorAll(".tab").forEach((el) => {
|
||
const an = el.dataset.tab === aktiverTab;
|
||
el.classList.toggle("on", an);
|
||
el.setAttribute("aria-current", an ? "page" : "false");
|
||
});
|
||
}
|
||
const go = (name, id) => {
|
||
// Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die
|
||
// Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf
|
||
// "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen
|
||
// und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren.
|
||
if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) {
|
||
CAR.service.buch.splice(entwurfIndex, 1);
|
||
entwurfIndex = null;
|
||
}
|
||
sheet = null;
|
||
/* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt
|
||
den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */
|
||
if (istTab(name)) routeStack = [];
|
||
else if (route.name !== name || route.id !== id) routeStack.push(route);
|
||
navRichtung = istTab(name) ? null : "rein";
|
||
route = { name, id }; render();
|
||
};
|
||
function zurueck() {
|
||
sheet = null;
|
||
const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" };
|
||
navRichtung = "raus";
|
||
route = ziel; render();
|
||
}
|
||
|
||
/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. */
|
||
let randX0 = null, randY0 = null;
|
||
function randwischenVerdrahten() {
|
||
const phone = ROOT.querySelector(".phone");
|
||
phone.addEventListener("pointerdown", (e) => {
|
||
randX0 = null;
|
||
if (!e.isPrimary || sheet || setupOffen) return;
|
||
const links = phone.getBoundingClientRect().left;
|
||
if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; }
|
||
});
|
||
phone.addEventListener("pointerup", (e) => {
|
||
if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck();
|
||
randX0 = null;
|
||
});
|
||
phone.addEventListener("pointercancel", () => { randX0 = null; });
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Aktionen
|
||
Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()),
|
||
Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge
|
||
wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */
|
||
function serviceRufen(dienst, daten) {
|
||
HASS.callService("pyscript", dienst, daten).catch((err) => console.error("audi_dashboard:", dienst, err));
|
||
}
|
||
|
||
function ereignisseVerdrahten() {
|
||
/* Tastaturbedienung fuer die beiden Ueberlagerungen. Bisher gab es in der
|
||
ganzen Datei keinen keydown-Handler: Das Setup-Fenster deklariert
|
||
role="dialog" aria-modal="true", liess sich aber weder mit Escape
|
||
schliessen noch verhinderte es, dass der Tabulator in den verdeckten
|
||
Hintergrund wandert. Beides gehoert zum Vertrag dieser Rolle.
|
||
Der Handler haengt am Dokument, weil Fokus im Shadow DOM auch ausserhalb
|
||
von ROOT liegen kann. */
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
if (sheet) { sheetSchliessen(); e.preventDefault(); return; }
|
||
if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; }
|
||
if (setupOffen) { setupSchliessen(); e.preventDefault(); return; }
|
||
return;
|
||
}
|
||
if (e.key !== "Tab" || !setupOffen) return;
|
||
/* Fokusfalle: innerhalb des Fensters im Kreis wandern statt dahinter. */
|
||
const fokussierbar = ROOT.querySelectorAll(
|
||
'.setup-popup button:not([disabled]), .setup-popup input:not([disabled]), .setup-popup [tabindex]:not([tabindex="-1"])');
|
||
if (!fokussierbar.length) return;
|
||
const erstes = fokussierbar[0];
|
||
const letztes = fokussierbar[fokussierbar.length - 1];
|
||
const aktiv = ROOT.activeElement;
|
||
if (e.shiftKey && (aktiv === erstes || !aktiv)) { letztes.focus(); e.preventDefault(); }
|
||
else if (!e.shiftKey && aktiv === letztes) { erstes.focus(); e.preventDefault(); }
|
||
});
|
||
ROOT.getElementById("tabbar").innerHTML = TABS.map((t) =>
|
||
`<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 auch die Leaflet-Kacheln den Stil wechseln: die
|
||
Kachel-URL wird beim Aufbau aus TILES[theme] gewaehlt und nicht
|
||
nachtraeglich aktualisiert. Ohne dies blieb die Karte bis zum naechsten
|
||
Backend-Update im alten Stil - seit der Dauerkarte auf der Uebersicht
|
||
deutlich sichtbar. */
|
||
render();
|
||
});
|
||
// Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe
|
||
// Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser).
|
||
// touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ
|
||
// zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier
|
||
// preventDefault() die Kontrolle.
|
||
ROOT.addEventListener("pointerdown", (e) => {
|
||
// Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) -
|
||
// unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist.
|
||
if (e.isPrimary && route.name === "home" && !ptrLaedt) {
|
||
const ansicht = $("#view");
|
||
if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; }
|
||
}
|
||
const wrap = e.target.closest(".swipe-wrap");
|
||
if (!wrap || !e.isPrimary) return;
|
||
swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0;
|
||
});
|
||
ROOT.addEventListener("pointermove", (e) => {
|
||
if (ptrY0 !== null && e.isPrimary) {
|
||
const ansicht = $("#view"), dy = e.clientY - ptrY0;
|
||
if (dy > 0 && ansicht && ansicht.scrollTop <= 0) {
|
||
e.preventDefault();
|
||
ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS
|
||
const ptr = $("#ptr");
|
||
if (ptr) {
|
||
ptr.style.transition = "none";
|
||
ptr.style.height = ptrDrag + "px";
|
||
ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren";
|
||
}
|
||
} else {
|
||
ptrY0 = null; ptrDrag = 0;
|
||
const ptr = $("#ptr");
|
||
if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; }
|
||
}
|
||
}
|
||
if (!swipeAktivWrap || !e.isPrimary) return;
|
||
const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY;
|
||
if (swipeGeste === null) {
|
||
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
|
||
swipeGeste = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||
if (swipeGeste === "y") { swipeAktivWrap = null; return; }
|
||
}
|
||
e.preventDefault();
|
||
swipeWarGezogen = true;
|
||
const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0;
|
||
swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx));
|
||
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
|
||
inhalt.style.transition = "none";
|
||
inhalt.style.transform = `translateX(${swipeDX}px)`;
|
||
}, { passive: false });
|
||
function swipeBeenden() {
|
||
if (!swipeAktivWrap) return;
|
||
const inhalt = swipeAktivWrap.querySelector(".swipe-content");
|
||
inhalt.style.transition = ""; inhalt.style.transform = "";
|
||
if (swipeGeste === "x") {
|
||
if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); }
|
||
else { swipeAktivWrap.classList.remove("swiped"); }
|
||
}
|
||
swipeAktivWrap = null; swipeGeste = null; swipeDX = 0;
|
||
// Der nachlaufende Klick derselben Geste feuert synchron noch in diesem
|
||
// Tick (Maus: immer: Touch: falls vom Browser nicht ohnehin unterdrückt) -
|
||
// erst danach zurücksetzen, sonst würde ein echter, späterer Tipp (z. B.
|
||
// auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt.
|
||
if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0);
|
||
}
|
||
function ptrBeenden() {
|
||
if (ptrY0 === null) return;
|
||
ptrY0 = null;
|
||
const ptr = $("#ptr"); if (!ptr) { ptrDrag = 0; return; }
|
||
ptr.style.transition = "height .2s";
|
||
if (ptrDrag >= 56) {
|
||
ptrLaedt = true;
|
||
ptr.style.height = "56px";
|
||
ptr.classList.add("spin");
|
||
ptr.querySelector("span").textContent = "Aktualisieren …";
|
||
datenAktualisieren().finally(() => {
|
||
ptrLaedt = false;
|
||
const ptr2 = $("#ptr");
|
||
if (ptr2) { ptr2.style.transition = "height .2s"; ptr2.style.height = "0"; ptr2.classList.remove("spin"); }
|
||
});
|
||
} else {
|
||
ptr.style.height = "0";
|
||
}
|
||
ptrDrag = 0;
|
||
}
|
||
ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); });
|
||
ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); });
|
||
|
||
// Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben
|
||
// (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt
|
||
// über die swipe*-Variablen oben, die für horizontales Wischen in Listen
|
||
// gedacht sind und mit einer zweiten aktiven Berührung nichts anfangen
|
||
// können.
|
||
ROOT.addEventListener("pointerdown", (e) => {
|
||
if (!e.target.closest("#bvChart") || !bvDomain) return;
|
||
bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (bvZeiger.size === 1) {
|
||
bvTapKandidat = { x: e.clientX, y: e.clientY };
|
||
bvBewegtSeit = false;
|
||
bvGestStart = { typ: "pan", x: e.clientX, domain: [...bvDomain] };
|
||
} else if (bvZeiger.size === 2) {
|
||
bvTapKandidat = null;
|
||
const [p1, p2] = [...bvZeiger.values()];
|
||
const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect);
|
||
const mitteX = (p1.x + p2.x) / 2;
|
||
const ankerFrac = Math.max(0, Math.min(1, (mitteX - rahmen.linksPx) / rahmen.breitePx));
|
||
bvGestStart = {
|
||
typ: "pinch", dist: Math.hypot(p1.x - p2.x, p1.y - p2.y), domain: [...bvDomain],
|
||
ankerFrac, ankerZeit: bvDomain[0] + ankerFrac * (bvDomain[1] - bvDomain[0]),
|
||
};
|
||
}
|
||
});
|
||
ROOT.addEventListener("pointermove", (e) => {
|
||
if (!bvZeiger.has(e.pointerId) || !bvGestStart) return;
|
||
bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
e.preventDefault();
|
||
if (bvGestStart.typ === "pan" && bvZeiger.size === 1) {
|
||
const dxPx = e.clientX - bvGestStart.x;
|
||
if (Math.abs(dxPx) > 4) bvBewegtSeit = true;
|
||
const svg = $("#bvSvg"), rect = svg.getBoundingClientRect();
|
||
const breite = bvGestStart.domain[1] - bvGestStart.domain[0];
|
||
const dt = -(dxPx / bvPlotRahmen(rect).breitePx) * breite;
|
||
bvDomain = bvDomainKlemmen([bvGestStart.domain[0] + dt, bvGestStart.domain[1] + dt]);
|
||
bvZeichnen();
|
||
} else if (bvGestStart.typ === "pinch" && bvZeiger.size === 2) {
|
||
const [p1, p2] = [...bvZeiger.values()];
|
||
const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||
const faktor = bvGestStart.dist / Math.max(dist, 1);
|
||
const breite = (bvGestStart.domain[1] - bvGestStart.domain[0]) * faktor;
|
||
const start = bvGestStart.ankerZeit - bvGestStart.ankerFrac * breite;
|
||
bvDomain = bvDomainKlemmen([start, start + breite]);
|
||
bvZeichnen();
|
||
}
|
||
}, { passive: false });
|
||
function bvBeenden(e) {
|
||
if (!bvZeiger.has(e.pointerId)) return;
|
||
bvZeiger.delete(e.pointerId);
|
||
if (bvZeiger.size === 0) {
|
||
if (bvGestStart && bvGestStart.typ === "pan" && !bvBewegtSeit && bvTapKandidat) {
|
||
bvAusgewaehlt = bvNaechsterPunkt(bvTapKandidat.x);
|
||
bvZeichnen();
|
||
}
|
||
bvGestStart = null; bvTapKandidat = null;
|
||
} else if (bvZeiger.size === 1) {
|
||
// von Pinch (2 Finger) auf Pan (1 Finger verbleibend) wechseln, statt
|
||
// die Geste einfach zu beenden - fühlt sich beim Loslassen eines
|
||
// Fingers sonst wie ein Ruckler an
|
||
const [rest] = [...bvZeiger.entries()];
|
||
bvGestStart = { typ: "pan", x: rest[1].x, domain: [...bvDomain] };
|
||
bvBewegtSeit = true;
|
||
}
|
||
}
|
||
ROOT.addEventListener("pointerup", bvBeenden);
|
||
ROOT.addEventListener("pointercancel", bvBeenden);
|
||
ROOT.addEventListener("wheel", (e) => {
|
||
if (!e.target.closest("#bvChart") || !bvDomain) return;
|
||
e.preventDefault();
|
||
const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect);
|
||
const frac = Math.max(0, Math.min(1, (e.clientX - rahmen.linksPx) / rahmen.breitePx));
|
||
const ankerZeit = bvDomain[0] + frac * (bvDomain[1] - bvDomain[0]);
|
||
const faktor = e.deltaY > 0 ? 1.2 : 1 / 1.2;
|
||
const breite = (bvDomain[1] - bvDomain[0]) * faktor;
|
||
const start = ankerZeit - frac * breite;
|
||
bvDomain = bvDomainKlemmen([start, start + breite]);
|
||
bvZeichnen();
|
||
}, { passive: false });
|
||
|
||
ROOT.addEventListener("click", (e) => {
|
||
// B4: Das Action Sheet liegt über allem und fängt daher zuerst ab.
|
||
if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; }
|
||
if (e.target.closest("[data-sheet-ok]")) {
|
||
const aktion = sheet && sheet.aktion;
|
||
sheet = null; render();
|
||
if (aktion) aktion();
|
||
return;
|
||
}
|
||
if (sheet) return;
|
||
if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; }
|
||
if (setupOffen) {
|
||
if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; }
|
||
const wahl = e.target.closest("[data-setup-wahl]");
|
||
if (wahl) {
|
||
const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity;
|
||
const teile = feldId.split(":");
|
||
if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId;
|
||
else setupZuordnung[feldId] = entityId;
|
||
setupSucheOffen = null; setupSuchtext = ""; render();
|
||
return;
|
||
}
|
||
const reset = e.target.closest("[data-setup-reset]");
|
||
if (reset) {
|
||
const feldId = reset.dataset.setupReset;
|
||
const teile = feldId.split(":");
|
||
if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = setupStandardwert(teile[0], Number(teile[1]));
|
||
else setupZuordnung[feldId] = setupStandardwert(feldId, null);
|
||
render();
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-setup-speichern]")) {
|
||
if (setupSpeichertLaeuft) return;
|
||
const duplikate = setupDuplikate();
|
||
if (duplikate.length) {
|
||
const namen = duplikate.map(entitaetName).join(", ");
|
||
bestaetigen("Doppelt zugeordnet", `${namen} ${duplikate.length === 1 ? "ist" : "sind"} mehr als einer Rolle zugeordnet. Trotzdem speichern?`, "Trotzdem speichern", setupSpeichernAusfuehren, false);
|
||
return;
|
||
}
|
||
setupSpeichernAusfuehren();
|
||
return;
|
||
}
|
||
// Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein
|
||
// Klick, während es schon offen ist (z. B. Cursor neu setzen), soll
|
||
// keinen render() auslösen - würde die laufende Eingabe unterbrechen.
|
||
const sucheInput = e.target.closest("[data-setup-suche]");
|
||
if (sucheInput) {
|
||
const feldId = sucheInput.dataset.setupSuche;
|
||
if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); }
|
||
return;
|
||
}
|
||
// Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon
|
||
// oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze
|
||
// Popup zu verlassen.
|
||
/* Den Filterschalter VOR dem Schliessen der Auswahlliste behandeln.
|
||
Sonst ersetzt render() unten das Overlay-DOM, bevor das
|
||
change-Ereignis des Kontrollkaestchens ausgeliefert wird - der
|
||
Schalter sprang dann beim naechsten Zeichnen einfach zurueck. */
|
||
if (e.target.closest("[data-setup-nurpassend]") || e.target.closest(".switch")) {
|
||
const kasten = ROOT.querySelector("[data-setup-nurpassend]");
|
||
if (kasten) { setupNurPassend = !setupNurPassend; render(); return; }
|
||
}
|
||
if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; }
|
||
return;
|
||
}
|
||
const bvReset = e.target.closest("[data-bvreset]");
|
||
if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; }
|
||
// Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung
|
||
// weiterhin) - nicht als Navigation/Aktion werten.
|
||
if (swipeWarGezogen) { swipeWarGezogen = false; return; }
|
||
const loeschBtn = e.target.closest("[data-swipe-loeschen]");
|
||
const offenerWrap = ROOT.querySelector(".swipe-wrap.swiped");
|
||
if (offenerWrap && !loeschBtn) {
|
||
// Erster Tipp bei offener Wischfläche schließt sie nur - wie bei iOS,
|
||
// egal ob innerhalb der Zeile (statt zu navigieren) oder außerhalb.
|
||
schliesseAlleSwipes();
|
||
if (offenerWrap.contains(e.target)) return;
|
||
}
|
||
if (loeschBtn) {
|
||
const [art, id] = loeschBtn.dataset.swipeLoeschen.split(":");
|
||
bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); });
|
||
return;
|
||
}
|
||
const tripDel = e.target.closest("[data-tripdel]");
|
||
if (tripDel) {
|
||
const id = tripDel.dataset.tripdel;
|
||
bestaetigen(LOESCH_TEXT.trip, LOESCH_HINWEIS.trip, "Löschen", () => { eintragLoeschen("trip", id); go("trips"); });
|
||
return;
|
||
}
|
||
const fillDel = e.target.closest("[data-filldel]");
|
||
if (fillDel) {
|
||
const id = fillDel.dataset.filldel;
|
||
bestaetigen(LOESCH_TEXT.fill, LOESCH_HINWEIS.fill, "Löschen", () => { eintragLoeschen("fill", id); go("fuel"); });
|
||
return;
|
||
}
|
||
const sbDel = e.target.closest("[data-sbdel]");
|
||
if (sbDel) {
|
||
const id = sbDel.dataset.sbdel;
|
||
bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); });
|
||
return;
|
||
}
|
||
const bildDel = e.target.closest("[data-bildloeschen]");
|
||
if (bildDel) {
|
||
const dateiname = bildDel.dataset.bildloeschen;
|
||
bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => {
|
||
(async () => {
|
||
try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); }
|
||
catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; }
|
||
bildMenuOffen = null; bildVersion = Date.now(); render();
|
||
})();
|
||
});
|
||
return;
|
||
}
|
||
// Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup
|
||
// mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl
|
||
// direkt öffnen.
|
||
const bildKlick = e.target.closest("[data-bildklick]");
|
||
if (bildKlick) {
|
||
const dateiname = bildKlick.dataset.bildklick;
|
||
const box = bildKlick.querySelector(".bildbox");
|
||
const container = bildKlick.closest(".tile, .bildslot");
|
||
if (box && box.classList.contains("bild-fehlt")) {
|
||
const input = container.querySelector(`[data-bildupload="${dateiname}"]`);
|
||
if (input) input.click();
|
||
} else {
|
||
bildMenuOffen = dateiname; render();
|
||
}
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; }
|
||
const bildErsetzen = e.target.closest("[data-bildersetzen]");
|
||
if (bildErsetzen) {
|
||
const container = bildErsetzen.closest(".tile, .bildslot");
|
||
const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`);
|
||
if (input) input.click();
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; }
|
||
if (e.target.closest("[data-sdbestaetigen]")) {
|
||
const datum = $("#sdPopupBis").value;
|
||
if (!datum) return;
|
||
CAR.smartdeal.aktiv = true; CAR.smartdeal.laeuftAb = datum;
|
||
smartdealPopupOffen = false; profilSpeichern(); render();
|
||
return;
|
||
}
|
||
if (e.target.id === "carimg") { bildWeiter(); return; }
|
||
if (e.target.closest("[data-sbneu]")) {
|
||
CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" });
|
||
entwurfIndex = 0; go("sbuch", "0"); return;
|
||
}
|
||
if (e.target.closest("[data-sbspeichern]")) {
|
||
entwurfIndex = null; profilSpeichern(); go("service"); return;
|
||
}
|
||
if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; }
|
||
if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; }
|
||
if (e.target.closest("[data-nmspeichern]")) {
|
||
const wert = parseInt($("#nm").value, 10);
|
||
if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); }
|
||
nmOffen = false; render(); return;
|
||
}
|
||
if (e.target.closest("[data-neu]")) {
|
||
neuOffen = !neuOffen;
|
||
if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null;
|
||
if (belegFehler && belegFehler.context === "neuTank") belegFehler = null;
|
||
render(); return;
|
||
}
|
||
if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; }
|
||
if (e.target.closest("[data-fahrtspeichern]")) {
|
||
const datum = $("#neuFahrtDatum").value, start = $("#neuFahrtStart").value, dauer = parseInt($("#neuFahrtDauer").value, 10) || 0;
|
||
const tsStart = new Date(`${datum}T${start}:00`);
|
||
const tsEnd = new Date(tsStart.getTime() + dauer * 60000);
|
||
serviceRufen("audi_dashboard_fahrt_manuell_anlegen", { ts_start: tsStart.toISOString(), ts_end: tsEnd.toISOString(), art: "privat" });
|
||
fahrtOffen = false; render(); return;
|
||
}
|
||
if (e.target.closest("[data-tankspeichern]")) {
|
||
const w = tankFormularWerte("neuTank");
|
||
if (w.liter > 0 && w.kosten > 0) {
|
||
serviceRufen("audi_dashboard_tankvorgang_manuell", {
|
||
ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station,
|
||
distanz: w.distanz, kraftstoff: w.kraftstoff,
|
||
});
|
||
}
|
||
neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
|
||
}
|
||
if (e.target.closest("[data-tankspeichernbeleg]")) {
|
||
const w = tankFormularWerte("neuTank");
|
||
const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null;
|
||
if (w.liter > 0 && w.kosten > 0) {
|
||
serviceRufen("audi_dashboard_tankvorgang_manuell", {
|
||
ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station,
|
||
distanz: w.distanz, kraftstoff: w.kraftstoff,
|
||
receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null,
|
||
});
|
||
}
|
||
neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
|
||
}
|
||
if (e.target.closest("[data-tankbearbeiten]")) {
|
||
tankBearbeitenOffen = !tankBearbeitenOffen;
|
||
if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null;
|
||
if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null;
|
||
render(); return;
|
||
}
|
||
const tankAkt = e.target.closest("[data-tankaktualisieren]");
|
||
if (tankAkt) {
|
||
const w = tankFormularWerte("bearbTank");
|
||
const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null;
|
||
serviceRufen("audi_dashboard_tankvorgang_aktualisieren", {
|
||
tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten,
|
||
km: w.km, ersparnis: w.ersparnis, station: w.station,
|
||
distanz: w.distanz, kraftstoff: w.kraftstoff,
|
||
receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null,
|
||
});
|
||
tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return;
|
||
}
|
||
const belegBtn = e.target.closest("[data-beleg]");
|
||
if (belegBtn) {
|
||
// "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe
|
||
// belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg-
|
||
// Ansicht) trägt den Beleg direkt in den bereits gespeicherten
|
||
// Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen.
|
||
const ziel = belegBtn.dataset.beleg;
|
||
const nurParsen = ziel === "neu" || ziel === "bearb";
|
||
const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null;
|
||
const tankId = nurParsen ? undefined : ziel;
|
||
const kontext = nurParsen ? praefix : ziel;
|
||
if (belegFehler && belegFehler.context === kontext) belegFehler = null;
|
||
belegZielPraefix = praefix;
|
||
const eingabe = document.createElement("input");
|
||
eingabe.type = "file"; eingabe.accept = "application/pdf";
|
||
eingabe.onchange = () => {
|
||
const datei = eingabe.files[0]; if (!datei) return;
|
||
const leser = new FileReader();
|
||
leser.onload = () => {
|
||
const base64 = leser.result.split(",")[1];
|
||
serviceRufen("audi_dashboard_beleg_hochladen", { pdf_base64: base64, dateiname: datei.name, tank_id: tankId });
|
||
};
|
||
leser.readAsDataURL(datei);
|
||
};
|
||
eingabe.click();
|
||
return;
|
||
}
|
||
const del = e.target.closest("[data-loeschen]");
|
||
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
|
||
const oel = e.target.closest("[data-oel]");
|
||
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
|
||
const mode = e.target.closest("[data-mode]");
|
||
if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
|
||
const pr = e.target.closest("[data-profil]");
|
||
if (pr && pr.dataset.profil === "export") {
|
||
const a = document.createElement("a");
|
||
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
|
||
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
|
||
return;
|
||
}
|
||
const bk = e.target.closest("[data-backup]");
|
||
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_backup_jetzt", {}); return; }
|
||
if (bk && bk.dataset.backup === "export") {
|
||
// Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in
|
||
// entitaeten.json und wäre nach einer Wiederherstellung verloren.
|
||
const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS,
|
||
entitaeten: (ENTITAETEN && ENTITAETEN.werte) || null };
|
||
const a = document.createElement("a");
|
||
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2));
|
||
a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-update-pruefen]")) {
|
||
if (!updatePruefend) {
|
||
updatePruefend = true; render();
|
||
(async () => {
|
||
try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); }
|
||
catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); }
|
||
await updateStatusAbrufen();
|
||
updatePruefend = false; render();
|
||
})();
|
||
}
|
||
return;
|
||
}
|
||
if (e.target.closest("[data-update-installieren]")) {
|
||
if (!updateInstallierend) bestaetigen(
|
||
"Update installieren?",
|
||
"Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.",
|
||
"Installieren",
|
||
() => { updateInstallierend = true; render();
|
||
(async () => {
|
||
try {
|
||
await HASS.callService("pyscript", "audi_dashboard_update_installieren", {});
|
||
await updateStatusAbrufen();
|
||
location.reload();
|
||
return;
|
||
} catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); }
|
||
await updateStatusAbrufen();
|
||
updateInstallierend = false; render();
|
||
})();
|
||
}, false);
|
||
return;
|
||
}
|
||
const cs = e.target.closest("[data-csv]");
|
||
if (cs) {
|
||
const w = cs.dataset.csv;
|
||
if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"],
|
||
TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
|
||
else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
|
||
FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 3) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
|
||
else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
|
||
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
|
||
return;
|
||
}
|
||
const satz = e.target.closest("[data-satz]");
|
||
if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
|
||
const k = e.target.closest("[data-ics]");
|
||
if (k) {
|
||
if (k.dataset.ics === "service") {
|
||
const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)];
|
||
const datum = $("#svDat").value;
|
||
CAR.service.vereinbart[t.art] = datum;
|
||
profilSpeichern();
|
||
ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name,
|
||
t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse);
|
||
render();
|
||
} else {
|
||
const datum = $("#rfDat").value;
|
||
CAR.reifen.wechsel.datum = datum;
|
||
CAR.reifen.wechsel.vereinbart = $("#rfOk").checked;
|
||
profilSpeichern();
|
||
const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer";
|
||
ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`);
|
||
}
|
||
return;
|
||
}
|
||
const a = e.target.closest("[data-acc]");
|
||
if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; }
|
||
const art = e.target.closest("[data-art]");
|
||
if (art) { const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; render(); return; }
|
||
// Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü-
|
||
// DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt).
|
||
if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; }
|
||
if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = SMAP_STIL === "satellit" ? "strasse" : "satellit"; standortTiles(); return; }
|
||
if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; }
|
||
if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; }
|
||
if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; }
|
||
if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; }
|
||
if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; }
|
||
const g = e.target.closest("[data-go]");
|
||
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
||
});
|
||
|
||
ROOT.addEventListener("input", (e) => {
|
||
const sucheFeld = e.target.dataset.setupSuche;
|
||
if (sucheFeld) {
|
||
// Nur die Kandidatenliste patchen, nicht render() - ein voller
|
||
// Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen
|
||
// und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim
|
||
// bestehenden data-tankpreisfeld-Handler direkt hier drunter).
|
||
setupSuchtext = e.target.value;
|
||
const teile = sucheFeld.split(":");
|
||
const key = teile[0];
|
||
const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key];
|
||
const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`);
|
||
if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert);
|
||
return;
|
||
}
|
||
const praefix = e.target.dataset.tankpreisfeld;
|
||
if (!praefix) return;
|
||
const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", "."));
|
||
const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", "."));
|
||
const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`);
|
||
if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 3)} €` : "Preis je Liter: wird berechnet";
|
||
});
|
||
|
||
ROOT.addEventListener("change", (e) => {
|
||
if (e.target.dataset.profil === "import") {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const leser = new FileReader();
|
||
leser.onload = () => {
|
||
try {
|
||
const p = JSON.parse(leser.result);
|
||
PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||
serviceRufen("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) });
|
||
bild = 0; render();
|
||
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
|
||
};
|
||
leser.readAsText(datei);
|
||
return;
|
||
}
|
||
if (e.target.dataset.backup === "import") {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const leser = new FileReader();
|
||
leser.onload = () => {
|
||
try {
|
||
const paket = JSON.parse(leser.result);
|
||
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)");
|
||
PROFIL_ROH = paket.profil;
|
||
CONFIG = profilZuConfig(paket.profil);
|
||
CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
|
||
TRIPS = paket.fahrten || [];
|
||
FILLS = paket.tankvorgaenge || [];
|
||
serviceRufen("audi_dashboard_backup_wiederherstellen", {
|
||
profil_json: JSON.stringify(paket.profil),
|
||
fahrten_json: JSON.stringify(TRIPS),
|
||
tankvorgaenge_json: JSON.stringify(FILLS),
|
||
// Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die
|
||
// aktuelle Zuordnung stehen, statt geleert zu werden.
|
||
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
|
||
});
|
||
bild = 0; render();
|
||
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
|
||
};
|
||
leser.readAsText(datei);
|
||
return;
|
||
}
|
||
if (e.target.dataset.bildupload !== undefined) {
|
||
const datei = e.target.files[0]; if (!datei) return;
|
||
const dateiname = e.target.dataset.bildupload;
|
||
const leser = new FileReader();
|
||
leser.onload = async () => {
|
||
const daten_base64 = String(leser.result).split(",")[1] || "";
|
||
try {
|
||
await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 });
|
||
} catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); }
|
||
bildMenuOffen = null; bildVersion = Date.now(); render();
|
||
};
|
||
leser.readAsDataURL(datei);
|
||
e.target.value = "";
|
||
return;
|
||
}
|
||
if (e.target.dataset.backupintervall !== undefined) {
|
||
CONFIG.backupIntervall = e.target.value; profilSpeichern(); return;
|
||
}
|
||
const ein = e.target.dataset.einrichten;
|
||
if (ein) {
|
||
if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value;
|
||
else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value;
|
||
else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value;
|
||
else CAR[ein] = e.target.value;
|
||
profilSpeichern(); return;
|
||
}
|
||
const vb = e.target.dataset.vereinbart;
|
||
if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); return; }
|
||
const ah = e.target.dataset.ah;
|
||
if (ah) {
|
||
CAR.service.autohaus[ah] = e.target.value;
|
||
profilSpeichern(); render(); return;
|
||
}
|
||
const sb = e.target.dataset.sb;
|
||
if (sb) {
|
||
const [i, feld] = sb.split("|");
|
||
const eintrag = CAR.service.buch[+i];
|
||
eintrag[feld] = feld === "km" ? (parseInt(e.target.value, 10) || 0) : feld === "kosten" ? (e.target.value === "" ? null : parseFloat(e.target.value)) : e.target.value;
|
||
// Ein per "Eintrag hinzufügen" angelegter, noch nicht bestätigter Entwurf
|
||
// wird erst beim Klick auf "Speichern" (data-sbspeichern) persistiert -
|
||
// sonst würde schon das testweise Ausfüllen einzelner Felder auto-speichern.
|
||
if (entwurfIndex !== +i) profilSpeichern();
|
||
render(); return;
|
||
}
|
||
const rf = e.target.dataset.rf;
|
||
if (rf) {
|
||
const [k, schluessel] = rf.split("|");
|
||
const wert = e.target.value;
|
||
CAR.reifen[k][schluessel] = wert;
|
||
profilSpeichern(); render(); return;
|
||
}
|
||
if (e.target.dataset.schrift !== undefined) {
|
||
beschriftung = e.target.checked;
|
||
try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ }
|
||
ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung);
|
||
return;
|
||
}
|
||
if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; }
|
||
const vteil = e.target.dataset.vteil;
|
||
if (vteil) {
|
||
const [i, feld] = vteil.split("|");
|
||
CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value;
|
||
CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0);
|
||
profilSpeichern(); render(); return;
|
||
}
|
||
if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; }
|
||
const vnotruf = e.target.dataset.vnotruf;
|
||
if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; }
|
||
if (e.target.dataset.sd !== undefined) {
|
||
if (e.target.checked) { smartdealPopupOffen = true; render(); }
|
||
else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); }
|
||
return;
|
||
}
|
||
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
|
||
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
|
||
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
|
||
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ Datenladen */
|
||
function zustandsDaten(entityId) {
|
||
const st = HASS && HASS.states[entityId];
|
||
return st && st.attributes ? st.attributes.daten : undefined;
|
||
}
|
||
|
||
let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = null;
|
||
|
||
/* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler
|
||
weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie
|
||
Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in
|
||
frontend_veroeffentlichung.py. */
|
||
function belegErgebnisVerarbeiten(daten) {
|
||
if (!daten) return;
|
||
if (daten.fehler) {
|
||
const kontext = daten.tank_id || belegZielPraefix;
|
||
belegFehler = { context: kontext, meldung: daten.fehler };
|
||
if (!daten.tank_id) belegEntwurf = null;
|
||
} else if (daten.tank_id) {
|
||
// Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der
|
||
// Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über
|
||
// tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene
|
||
// Fehleranzeige für diesen Tankvorgang löschen.
|
||
if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null;
|
||
} else if (belegZielPraefix) {
|
||
belegEntwurf = { praefix: belegZielPraefix, daten };
|
||
belegFehler = null;
|
||
}
|
||
render();
|
||
}
|
||
|
||
/* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element
|
||
frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch
|
||
Minuten nach dem Start und nach SPA-interner Navigation weg und zurück
|
||
beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand
|
||
hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim
|
||
allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem
|
||
HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv
|
||
auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen
|
||
Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant,
|
||
nicht uns.
|
||
|
||
Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real
|
||
zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die
|
||
VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger
|
||
dauern, bis pyscript überhaupt geladen ist und sein @time_trigger("startup")
|
||
feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App
|
||
blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch
|
||
kein aktives Symbol, weil render() den harten Rücksprung bei
|
||
!DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half,
|
||
weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu
|
||
dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit.
|
||
Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen
|
||
Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber
|
||
endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt
|
||
wird (disconnectedCallback stoppt hier nichts explizit, aber ein
|
||
getimeter Aufruf ohne HASS/callWS bricht oben ohnehin ab). */
|
||
const NACHGELADEN = {};
|
||
let nachladeTimer = null;
|
||
let nachladeVersuche = 0;
|
||
const NACHLADE_INTERVALL_MS = 2000;
|
||
const NACHLADE_LANGSAM_MS = 10000;
|
||
const NACHLADE_SCHNELLE_VERSUCHE = 15;
|
||
|
||
function nachladeAnstossen() {
|
||
if (nachladeTimer || !HASS || !HASS.callWS) return;
|
||
nachladeVersuche++;
|
||
// Erster Versuch fast sofort statt erst nach vollem Intervall - der
|
||
// Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist
|
||
// schon nach wenigen hundert Millisekunden entschieden, nicht erst nach
|
||
// den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich
|
||
// noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer
|
||
// im schnellen Takt weiterzufragen.
|
||
const verzoegerung = nachladeVersuche === 1 ? 200
|
||
: nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS
|
||
: NACHLADE_LANGSAM_MS;
|
||
nachladeTimer = setTimeout(async () => {
|
||
nachladeTimer = null;
|
||
try {
|
||
const states = await HASS.callWS({ type: "get_states" });
|
||
const finden = (id) => states.find((s) => s.entity_id === id);
|
||
NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil;
|
||
NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten;
|
||
NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank;
|
||
NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status;
|
||
NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt;
|
||
// Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
|
||
// bis zufaellig ein Push kommt - siehe setupBereit().
|
||
NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten;
|
||
} catch (e) {
|
||
// wird beim naechsten Versuch erneut probiert
|
||
}
|
||
datenLaden(true);
|
||
}, verzoegerung);
|
||
}
|
||
|
||
async function datenLaden(erzwingen) {
|
||
if (!HASS) return;
|
||
const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil;
|
||
const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten;
|
||
const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank;
|
||
const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status;
|
||
const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt;
|
||
const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"];
|
||
// Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/
|
||
// fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern,
|
||
// nicht Teil des normalen Update-Rhythmus.
|
||
const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"] || NACHGELADEN.entitaeten;
|
||
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
|
||
|
||
if (belegState && belegState !== letzterBelegState) {
|
||
letzterBelegState = belegState;
|
||
belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten);
|
||
}
|
||
|
||
const geaendert = erzwingen
|
||
|| profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState
|
||
|| tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState;
|
||
if (geaendert) {
|
||
letzterProfilState = profilState; letzterFahrtenState = fahrtenState;
|
||
letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState;
|
||
|
||
const profil = profilState && profilState.attributes ? profilState.attributes.daten : null;
|
||
if (profil) {
|
||
const status = (statusState && statusState.attributes && statusState.attributes.daten) || {};
|
||
LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated)
|
||
? new Date(statusState.last_updated).getTime() : null;
|
||
PROFIL_ROH = profil;
|
||
CONFIG = profilZuConfig(profil);
|
||
CAR = profilZuCar(profil, status);
|
||
TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || [];
|
||
FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || [];
|
||
BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || [];
|
||
DATEN_GELADEN = true;
|
||
nachladeVersuche = 0;
|
||
if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); }
|
||
render();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart)
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Element */
|
||
class AudiDashboardPanel extends HTMLElement {
|
||
constructor() {
|
||
super();
|
||
this.attachShadow({ mode: "open" });
|
||
|
||
/* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes
|
||
Modul (dieses hier) mit customElements.define() fertig registriert
|
||
ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für
|
||
module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js):
|
||
_createPanel() kann mehrfach anlaufen, und beim ersten, noch zu
|
||
frühen document.createElement(tag) ist "audi-dashboard-panel" für
|
||
den Browser noch ein unbekanntes Element. Die anschließende
|
||
Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem
|
||
Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft
|
||
auf der Instanz an. Sobald customElements.define() greift und der
|
||
Browser das Element "aufwertet" (führt genau diesen Konstruktor
|
||
nach), holt die Plattform das NICHT automatisch nach - das gilt nur
|
||
für über observedAttributes beobachtete HTML-Attribute, nicht für
|
||
beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass()
|
||
dauerhaft unerreichbar: _aufbauen() lief zwar (über
|
||
connectedCallback(), einen echten Lifecycle-Hook), aber
|
||
datenLaden() nie - sichtbares Symptom war ein dauerhaft
|
||
hängendes "Lädt …" trotz vorhandener Backend-Daten. */
|
||
for (const eigenschaft of ["hass", "narrow", "panel"]) {
|
||
if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) {
|
||
const wert = this[eigenschaft];
|
||
delete this[eigenschaft];
|
||
this[eigenschaft] = wert;
|
||
}
|
||
}
|
||
}
|
||
|
||
set hass(hass) {
|
||
const erster = !HASS;
|
||
HASS = hass;
|
||
if (erster) {
|
||
this._aufbauen();
|
||
}
|
||
datenLaden(false);
|
||
}
|
||
get hass() { return HASS; }
|
||
|
||
set narrow(v) { this._narrow = v; }
|
||
set panel(v) { this._panel = v; }
|
||
|
||
connectedCallback() {
|
||
HOST = this;
|
||
if (!this.shadowRoot.querySelector(".phone")) this._aufbauen();
|
||
// Absicherung fuer den Fall, dass panel_custom das Element neu verbindet
|
||
// (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut
|
||
// auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand
|
||
// haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt.
|
||
if (!DATEN_GELADEN) datenLaden(false);
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
// Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels
|
||
// (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
|
||
// window-Resize-Horcher und laufenden Kachelabrufen am Leben.
|
||
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
||
if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; }
|
||
if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; }
|
||
}
|
||
|
||
_aufbauen() {
|
||
// Idempotenz-Sperre: sowohl connectedCallback() als auch set hass()
|
||
// können _aufbauen() auslösen, je nachdem in welcher Reihenfolge
|
||
// panel_custom das Element einfügt und mit hass versorgt. Ohne diese
|
||
// Sperre lief der Aufbau zweimal, verdrahtete jeden Knopf doppelt und
|
||
// ein Klick auf Tag/Nacht schaltete zweimal um - macht sich gegenseitig
|
||
// zunichte, sichtbar erst beim tatsächlichen Testen, nicht im Code.
|
||
if (this.shadowRoot.querySelector(".phone")) return;
|
||
ROOT = this.shadowRoot;
|
||
HOST = this;
|
||
/* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den
|
||
Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */
|
||
HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt();
|
||
|
||
const link = document.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = `/local/audi-dashboard.css?v=${CSS_VERSION}`;
|
||
ROOT.appendChild(link);
|
||
|
||
/* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab
|
||
860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */
|
||
const linkIos = document.createElement("link");
|
||
linkIos.rel = "stylesheet";
|
||
linkIos.href = `/local/audi-dashboard-ios.css?v=${CSS_VERSION}`;
|
||
ROOT.appendChild(linkIos);
|
||
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = `
|
||
<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"><span class="label">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);
|
||
}
|
||
}
|
||
|
||
if (!customElements.get("audi-dashboard-panel")) {
|
||
customElements.define("audi-dashboard-panel", AudiDashboardPanel);
|
||
}
|
||
|