e1d570992e
Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3131 lines
182 KiB
JavaScript
3131 lines
182 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:
|
||
- Statistik-Seite (vStat/ST) zeigt weiterhin Beispielzahlen, keine echte
|
||
Auswertung aus TRIPS/FILLS - eigenes Arbeitspaket.
|
||
- 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 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,
|
||
wlanName: p.fahrzeug.wlan_name,
|
||
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,
|
||
reichweite: status.reichweite_km,
|
||
batteriespannung: status.batteriespannung,
|
||
gesichert: status.gesichert,
|
||
sicherheitscheck: status.sicherheitscheck || [],
|
||
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.wlan_name = CONFIG.wlanName;
|
||
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;
|
||
const mmjj = (d) => String(d.getMonth() + 1).padStart(2, "0") + "/" + String(d.getFullYear()).slice(2);
|
||
const dedat = (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";
|
||
|
||
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);
|
||
}
|
||
|
||
/* -------------------------------------------------------------- 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();
|
||
const zusatz = [modellZusatz, CONFIG.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">${pct} %</span>
|
||
</div>
|
||
<div class="balken"><i style="width:${pct}%;background:${balkenFarbe}"></i></div>
|
||
<span class="label" style="margin-top:12px">Reichweite${liter != null ? ` · noch etwa ${de(liter)} l im Tank` : ""}</span>
|
||
</div>
|
||
<div class="duo">
|
||
<button class="tile tilebtn" data-go="sicherheit:x"><span class="label">Fahrzeugstatus</span>
|
||
<div class="wert" style="margin-top:10px;color:var(--fg);font-size:15px">${g === null ? "unbekannt" : g ? "gesichert" : "prüfen"}</div></button>
|
||
<button class="tile tilebtn" data-go="service:x"><span class="label">Service</span>
|
||
<div class="wert" style="margin-top:10px;color:var(--fg);font-size:15px">${t ? esc(t.art) : "kein Eintrag"}</div></button>
|
||
<button class="tile tilebtn" data-go="reifen:x"><span class="label">Reifen</span>
|
||
<div class="wert" style="margin-top:10px;color:var(--fg);font-size:15px">${esc(CAR.reifen.aktiv)}</div></button>
|
||
<button class="tile tilebtn" data-go="fuel:x"><span class="label">Tanken</span>
|
||
<div class="wert" style="margin-top:10px;color:var(--fg);font-size:15px">${FILLS.length ? de(FILLS.length) + " Vorgänge" : "noch keiner"}</div></button>
|
||
</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>
|
||
${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(--red)", "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"><label for="einAusfuehrung">Ausführung</label>
|
||
<input id="einAusfuehrung" value="${esc(CONFIG.ausfuehrung || "")}" data-einrichten="ausfuehrung"></div>
|
||
<div class="feld" style="border-bottom:0"><label for="einWlan">Fahrzeug-WLAN (SSID)</label>
|
||
<input id="einWlan" value="${esc(CONFIG.wlanName || "")}" data-einrichten="wlan" placeholder="z. B. Audi_MMI_XXXX_5GHz"></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 ${einrichtenOffen ? "primaer" : ""}" data-einrichten-auf style="margin-top:16px">${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>EU Data Act<small>Abruf alle 15 Minuten</small></dd></div>
|
||
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>iPhone<small>Companion App</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;
|
||
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
|
||
das Telefon einmal im Fahrzeug-WLAN war. */
|
||
if (!jahre.length) return `<div class="tile"><div class="leer">
|
||
<strong>Noch keine Fahrten</strong>
|
||
<p>Fahrten werden erkannt, sobald sich das Telefon mit dem WLAN des Fahrzeugs verbindet (${esc(CONFIG.wlanName || "noch nicht eingerichtet")}). 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(--red)"></i><i style="flex:${privatKm};background:var(--fg3)"></i></div>
|
||
<div class="legend"><span><i style="background:var(--red)"></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"/>',
|
||
audi: '<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" transform="translate(0,0)"/>',
|
||
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" },
|
||
];
|
||
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",
|
||
};
|
||
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>`;
|
||
}
|
||
|
||
let letzteAnsicht = null;
|
||
let routeStack = [];
|
||
let navRichtung = null;
|
||
const istTab = (name) => TABS.some((t) => t.id === name);
|
||
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 { 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;
|
||
ROOT.getElementById("overlay").innerHTML = sheetMarkup();
|
||
/* 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 || "");
|
||
if (MAP) { MAP.remove(); MAP = null; TILE = null; }
|
||
if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer
|
||
ROOT.querySelectorAll(".tab").forEach((el) => el.classList.toggle("on", el.dataset.tab === route.name));
|
||
}
|
||
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) 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() {
|
||
ROOT.getElementById("tabbar").innerHTML = TABS.map((t) =>
|
||
`<button class="tab" data-tab="${t.id}">
|
||
<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true">${ICONS[t.id]}</svg><span>${t.label}</span>
|
||
</button>`).join("");
|
||
ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung);
|
||
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"));
|
||
ROOT.getElementById("theme").addEventListener("click", () => {
|
||
themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht");
|
||
themeIcon();
|
||
});
|
||
// 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;
|
||
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") {
|
||
const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS };
|
||
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; }
|
||
const g = e.target.closest("[data-go]");
|
||
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
||
});
|
||
|
||
ROOT.addEventListener("input", (e) => {
|
||
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),
|
||
});
|
||
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 === "wlan") CONFIG.wlanName = 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.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;
|
||
} 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"];
|
||
|
||
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() {
|
||
if (MAP) { MAP.remove(); MAP = 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);
|
||
|
||
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> <div class="rings" id="marke" aria-label="Audi"><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></div>
|
||
<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();
|
||
themeIcon();
|
||
setInterval(standAlterTicken, 1000);
|
||
}
|
||
}
|
||
|
||
if (!customElements.get("audi-dashboard-panel")) {
|
||
customElements.define("audi-dashboard-panel", AudiDashboardPanel);
|
||
}
|
||
|