/* ================================================================
Audi-Dashboard — panel_custom Custom Element
================================================================
Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp
aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant.
Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert
(dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde
nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den
pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die
DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).
Bekannte Lücken in diesem Stand:
- Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
- Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur,
wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat.
*/
/* ---------------------------------------------------------------------
Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch
unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`)
weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel-
Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine
Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein
existierte.
--------------------------------------------------------------------- */
let HASS = null;
let ROOT = null; // this.shadowRoot der Panel-Instanz
let HOST = null; // die Panel-Instanz selbst (für :host([data-theme]))
let CONFIG = null;
let CAR = null;
let TRIPS = [];
let FILLS = [];
let BATTERIEVERLAUF = [];
let 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 `
`;
}
/* ------------------------------------------------- Batteriespannungs-Verlauf
Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt,
siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per
Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt.
bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit
Zoom/Pan flüssig bleiben. */
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null;
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
let bvTapKandidat = null, bvBewegtSeit = false;
const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22;
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei
aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle
AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V,
50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch
entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V -
deutlich über dem Ruhespannungs-Maximum.
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet,
und zwar auf den Tagesminimalwert: der fällt typischerweise auf den
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
tauchen entsprechend nicht in der Ladezustands-Statistik auf. */
const AGM_RUHE_MAX_V = 13.2;
const BV_SOC_TABELLE = [
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
];
function bvSocProzent(v) {
const t = BV_SOC_TABELLE;
if (v <= t[0][0]) return 0;
if (v >= t[t.length - 1][0]) return 100;
for (let i = 0; i < t.length - 1; i++) {
const [v0, s0] = t[i], [v1, s1] = t[i + 1];
if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0));
}
return 100;
}
function bvSocLabel(soc) {
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch";
}
// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei
// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) -
// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok),
// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden.
function bvHexZuRgb(hex) {
const h = hex.trim().replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); }
function bvFarbstufen() {
const stil = getComputedStyle(HOST);
return {
bad: bvHexZuRgb(stil.getPropertyValue("--bad")),
warn: bvHexZuRgb(stil.getPropertyValue("--warn")),
ok: bvHexZuRgb(stil.getPropertyValue("--ok")),
};
}
function bvFarbe(v, stufen) {
let rgb;
if (v <= 11.6) rgb = stufen.bad;
else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6));
else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2));
else rgb = stufen.ok;
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
}
/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine
Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe
Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of-
health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt,
nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase
der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen
eingeordnet (übliche Lebensdauer 4-6 Jahre). */
function bvSohBewertung() {
const TAG_MS = 86400000;
if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." };
const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS;
if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." };
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS;
const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs);
const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs);
const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length;
const delta = neuD - altD;
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad";
const text = delta >= -0.05
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
: delta >= -0.15
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten.";
return { status, text, delta };
}
function vBatterieverlauf() {
// Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem
// Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann.
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V)
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum }))
.sort((a, b) => a.t - b.t);
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
const genug = bvPunkte.length >= 2;
bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null;
bvDomain = genug ? [...bvVollDomain] : null;
const stufen = bvFarbstufen();
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
const soc = letzter ? bvSocProzent(letzter.v) : null;
const soh = bvSohBewertung();
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
const leerText = BATTERIEVERLAUF.length === 0
? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist."
: "Noch zu wenige Messwerte für einen Verlauf.";
return `
Verlauf
${genug ? `
Ziehen zum Verschieben, mit zwei Fingern oder dem Mausrad
zoomen, auf einen Punkt tippen für Datum, Uhrzeit sowie Min-/Max-Spannung
` : `
${leerText}
`}
Farbskala Ladezustand (AGM, Ruhespannung)
${soh.text}Näherung auf Basis der täglich niedrigsten gemessenen Spannung (aussagekräftig für die
Entladung), keine Kapazitätsmessung - Tageswerte über ${de(AGM_RUHE_MAX_V, 1)} V (Ladevorgang während der Fahrt) fließen nicht ein.
Für eine verlässliche Aussage ist ein Lasttest an der Batterie nötig.
`;
}
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); }
function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); }
function bvDomainKlemmen(domain) {
let [a, b] = domain;
const volle = bvVollDomain[1] - bvVollDomain[0];
let breite = Math.min(b - a, volle);
breite = Math.max(breite, volle * 0.02);
if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; }
if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; }
return [a, b];
}
function bvPlotRahmen(rect) {
return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) };
}
function bvZeichnen() {
const svg = $("#bvSvg");
if (!svg || !bvDomain) return;
const sichtbar = bvPunkte.filter((p) => p.t >= bvDomain[0] && p.t <= bvDomain[1]);
const basis = sichtbar.length >= 2 ? sichtbar : bvPunkte;
let yMin = Math.min(...basis.map((p) => p.v)), yMax = Math.max(...basis.map((p) => p.v));
if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; }
const pad = (yMax - yMin) * 0.15; yMin -= pad; yMax += pad;
// etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am
// Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt
// weiterläuft
const spanne = bvDomain[1] - bvDomain[0];
const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05);
const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" ");
const gitter = [0, 1, 2, 3].map((i) => {
const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax);
return `${de(v, 1)}`;
}).join("");
const xLabels = [0, 0.5, 1].map((f) => {
const x = BV_ML + f * (BV_W - BV_ML - BV_MR);
const t = bvDomain[0] + f * spanne;
return `${kurzdat(new Date(t))}`;
}).join("");
const stufen = bvFarbstufen();
const kreise = rahmen.map((p) => {
const idx = bvPunkte.indexOf(p);
const gewaehlt = idx === bvAusgewaehlt;
return ``;
}).join("");
svg.innerHTML = `${gitter}${kreise}${xLabels}`;
const rangeEl = $("#bvRange");
if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`;
const tip = $("#bvTooltip");
if (tip) {
if (bvAusgewaehlt != null) {
const p = bvPunkte[bvAusgewaehlt];
const zeit = new Date(p.minTs);
tip.style.display = "block";
tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`;
} else {
tip.style.display = "none";
}
}
}
function bvNaechsterPunkt(clientX) {
const svg = $("#bvSvg"); if (!svg) return null;
const rect = svg.getBoundingClientRect();
const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]);
let beste = null, besterAbstand = Infinity;
bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } });
return beste;
}
let svAuswahl = 0;
function vService() {
const sv = CAR.service;
return `
Anstehende Termine
${termine().map((t, i, arr) => {
const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : "";
const iv = intervalle(t);
if (!iv) return `
${t.art}
kein Eintrag im Servicebuch
`;
const anzeige = iv.hersteller || iv.eigen;
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
// Forecast-Zeile nur bei Ölwechsel (Kundeneigenes Intervall aus
// "Einrichten", eigenständige Reset-nach-Service-Logik, siehe
// oelwechselPrognose()) - bei Inspektion und Hauptuntersuchung
// bewusst keine Forecast-Zeile mehr.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
return `
${t.art}
${t.vereinbart ? ` Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""}
Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.
${vSteuer()}`;
}
function vNotrufBearbeiten() {
const v = CAR.versicherung;
return `
Rufnummern ändern
`;
}
function vBeitrag() {
const v = CAR.versicherung;
return `
Beitrag anpassen
€
€
€
Der Jahresbeitrag oben ist die Summe dieser drei Posten und
rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel
`;
}
function vVertragsdetails() {
const v = CAR.versicherung;
return `
${v.leistungen.map((g) => `
${esc(g.gruppe)}
${g.posten.map((x, i) => `
${esc(x)}
`).join("")}
`).join("")}`;
}
function vSchutz() {
const v = CAR.versicherung, sb = v.schutzbrief;
return `
Die Entscheidung am Schadenort
Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die
anderen beiden.
`;
}
function vSteuerBearbeiten() {
const t = CAR.steuer;
return `
Kfz-Steuer
€
`;
}
/* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau
der Menüleiste wieder angewendet. */
const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift";
let beschriftung = (() => {
try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; }
})();
let bodenAn = true;
function csvDatei(name, kopf, zeilen) {
const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n");
const a = document.createElement("a");
a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt);
a.download = name; document.body.appendChild(a); a.click(); a.remove();
}
function vEinst() {
return `
Fahrzeug einrichten
Fahrgestellnummer, Kennzeichen, Erstzulassung, Ausführung und Ölwechsel-Intervall — bei der
Ersteinrichtung oder wenn sich diese Angaben ändern.
${einrichtenOffen ? `
Ölwechsel-Intervall
${CAR.oel.modus === "hersteller"
? `
Laufleistung
${de(CAR.oel.herstellerKm)} km
Zeit
${CAR.oel.herstellerMonate} Monate
Aus dem Fahrzeug gelesen`
: `
`}
` : ""}
Darstellung
Aus zeigt nur die Symbole und gibt etwas Höhe frei
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
` :
`Ersparnis und Bildmarke werden ausgeblendet`}
${smartdealPopupOffen ? `
` : ""}
Fahrten zusammenfassen
Minuten
Steigst du kürzer als diese Zeit aus — etwa zum Tanken —,
bleibt es eine zusammenhängende Fahrt statt zweier
Fahrzeugprofil
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
diese Datei getauscht — die App selbst bleibt unverändert.
Backup
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine
Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen
Gesamtstand als einzelne Datei.
${CONFIG.letztesBackup ? `Letztes Backup:
${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""}
Daten ausgeben
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
Tabellenprogrammen aufgehen.
Version
Dashboard
${esc(CONFIG.version)}
Fahrzeugdaten
EU Data ActAbruf alle 15 Minuten
Position
iPhoneCompanion App
${(() => {
const s = UPDATE_STATUS;
if (!s || s.stand === "keine_quelle") return `
${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"}
`;
if (s.stand === "fehler") return `
${esc(s.fehler || "Update-Prüfung fehlgeschlagen")}
`;
if (s.stand === "geprueft" && s.verfuegbar) return `
Update verfügbar — Version ${esc(s.neue_version)}
`;
return `
Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""}
`;
})()}
`;
}
let nmOffen = false;
let einrichtenOffen = false;
let bildMenuOffen = null;
let smartdealPopupOffen = false;
function vReifen() {
const r = CAR.reifen;
const feld = (k, schluessel, bez, typ) => {
const t = r[k];
return `
${satz("sommer")}
${satz("winter")}`;
}
function vTripsFormular() {
// eigene Funktion, damit sowohl der volle als auch der Leer-Zustand
// (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe
// Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf
// wechselte zu "Abbrechen", aber es erschienen keine Felder.
return `${fahrtOffen ? `
Neue Fahrt
` : ""}
`;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
/* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn
das Telefon einmal im Fahrzeug-WLAN war. */
if (!jahre.length) return `
Noch keine Fahrten
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.
${vTripsFormular()}`;
let h = `
`;
jahre.forEach((j, ji) => {
const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j);
h += `
`; }
function statTile(id, label, fig, unit, extra, body) {
return `
${body}
`;
}
/* Echte Auswertung aus TRIPS/FILLS statt Beispielwerten. Nur abgeschlossene
Fahrten/Tankvorgänge zählen (distance_km bzw. liters gesetzt) - offene/
unvollständige Einträge haben noch keinen belastbaren Wert.
Der Verbrauch ist eine Näherung (getankte Menge im Zeitraum ÷ gefahrene
km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
l/100km-Wert. */
function periodStart(art) {
const jetzt = new Date();
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
if (art === "woche") {
const d = new Date(jetzt); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d;
}
const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art));
const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art));
function istNachtZeit(ts) {
const z = new Date(ts).toTimeString().slice(0, 5), von = CONFIG.nachtVon, bis = CONFIG.nachtBis;
return von > bis ? (z >= von || z < bis) : (z >= von && z < bis);
}
/* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur
zwischen den Orten, nie am Pfeil. */
function strecke(t) {
const a = t.start_address, b = t.end_address;
if (!a && !b) return "Fahrt ohne Ortsangabe";
if (!b) return esc(a);
if (!a) return esc(b);
return `${esc(a)} → ${esc(b)}`;
}
/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird
vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */
function schnittFahrtVerbrauch(list) {
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
const km = sum(mit, (t) => t.distance_km);
return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–";
}
/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum
letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der
Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte
oder ein Tankvorgang noch unvollständig war. */
function langzeitverbrauch() {
const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts));
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
const letzter = tanks[tanks.length - 1];
const bis = new Date(letzter.ts);
const liter = sum(tanks, (f) => f.liters);
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
const mitOdo = tanks.filter((f) => f.odometer_km != null);
let km = 0, basis = "erfasste Fahrten";
if (mitOdo.length >= 2) {
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
basis = "Kilometerstand an den Tankvorgängen";
} else {
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
}
const roh = km > 0 ? (liter / km) * 100 : null;
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
const gueltig = roh != null && roh >= 3 && roh <= 30;
return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig };
}
function vStat() {
const jahr = new Date().getFullYear();
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
const tJahr = tankSeit("jahr");
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
const nachtKm = kmJahr - tagKm;
const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg");
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
const lv = langzeitverbrauch();
return `
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))}
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
`
Getankt insgesamt
${de(lv.liter, 1)} l
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""}
${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
`
Arbeitsweg · ${de(arbeitKm)} kmPrivat · ${de(privatKm)} km
Fahrten Arbeitsweg
${de(arbeitFahrten.length)}
Fahrten privat
${de(privatFahrten.length)}
`)}`;
}
let neuOffen = false;
let fahrtOffen = false;
let tankBearbeitenOffen = false;
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
let belegEntwurf = null;
// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"):
// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder
// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war.
let belegFehler = null;
// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher
// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine
// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null).
let belegZielPraefix = null;
/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog
zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit
einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über
Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events),
damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben
"art:id"-Schema wie data-go, siehe eintragLoeschen(). */
let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0;
let swipeWarGezogen = false;
const SWIPE_BREITE = 84;
/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des
Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene
Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig
voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer
Listenzeile UND ganz oben gescrollt). */
let ptrY0 = null, ptrDrag = 0, ptrLaedt = false;
async function datenAktualisieren() {
try {
await HASS.callService("pyscript", "audi_dashboard_jetzt_aktualisieren", {});
const states = await HASS.callWS({ type: "get_states" });
const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_fahrzeugstatus");
if (frisch) NACHGELADEN.status = frisch;
} catch (e) {
// bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt
// holt den Stand ohnehin nach
}
datenLaden(true);
}
function schliesseAlleSwipes() {
ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped"));
}
function swipeZeile(id, inhaltHtml) {
return `
${inhaltHtml}
`;
}
const LOESCH_TEXT = {
trip: "Fahrt löschen?",
fill: "Tankvorgang löschen?",
sbuch: "Servicebuch-Eintrag löschen?",
bild: "Foto löschen?",
};
const LOESCH_HINWEIS = {
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.",
sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.",
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
};
function eintragLoeschen(art, id) {
if (art === "trip") {
serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id });
TRIPS = TRIPS.filter((t) => t.trip_id !== id);
} else if (art === "fill") {
serviceRufen("audi_dashboard_tankvorgang_loeschen", { tank_id: id });
FILLS = FILLS.filter((f) => f.tank_id !== id);
} else if (art === "sbuch") {
CAR.service.buch.splice(parseInt(id, 10), 1);
profilSpeichern();
}
}
/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht
bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite
offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern
und den Verwerfen-Schritt in go(). */
let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
/* Update-Funktion (Einstellungen -> "Update suchen", siehe
updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht
zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade-
Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt
passiv auf den nächsten hass-Push zu warten. */
let UPDATE_STATUS = null;
let UPDATE_STATUS_GEHOLT = false;
let updatePruefend = false;
let updateInstallierend = false;
async function updateStatusAbrufen() {
try {
const states = await HASS.callWS({ type: "get_states" });
const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status");
UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null;
} catch (e) {
// zeigt dann weiterhin den zuletzt bekannten Stand
}
}
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details
und die technischen Daten hängen bewusst nicht daran - die bleiben, auch
bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen.
Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe
BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches
oder gar kein Badge auf der Übersicht. */
const FAHRZEUG_MODELLE = [
"RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant",
"RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance",
"RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7",
];
/* Badge-Zuordnung (www/badges/-positive.svg / -negative.svg, jeweils
das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der
FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der
Präfixe mit einem anderen überschneidet. */
const BADGE_DATEIEN = {
"RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7",
"S3": "s3", "SQ7": "sq7",
};
function badgePraefix() {
const titel = CONFIG.fahrzeugtitel || "";
return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null;
}
/* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines
bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen,
damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein
Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den
input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */
const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"];
/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum
zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel
wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py),
hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim
Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus
f.distance_km (siehe Aufrufer). */
function distanzVorschlag(aktuellerKm) {
if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return "";
const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
if (letzte.odometer_km == null) return "";
return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10;
}
function tankFelder(praefix, f) {
// Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf)
// überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen
// immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2).
const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null;
const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f;
const ts = q && q.ts ? new Date(q.ts) : new Date();
const datum = ts.toISOString().slice(0, 10);
const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0");
const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : "");
const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km);
const liter = q && q.liters != null ? q.liters : "";
const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : "";
const ersparnis = q && q.discount != null ? q.discount : "";
const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0];
const station = q && q.station_name ? q.station_name : "";
const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0
? `Preis je Liter: ${de(q.fuel_total_eur / q.liters, 3)} €` : "Preis je Liter: wird berechnet";
return `
km
€
${CAR.smartdeal.aktiv ? `
€
` : ""}
${preisText}`;
}
function tankFormularWerte(praefix) {
const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", "."));
const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", "."));
const kmFeld = $(`#${praefix}Km`).value;
const distanzFeld = $(`#${praefix}Distanz`).value;
const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null;
const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value;
return {
ts: new Date(`${datum}T${zeit}:00`).toISOString(),
liter, kosten,
km: kmFeld === "" ? null : parseFloat(kmFeld),
distanz: distanzFeld === "" ? null : parseFloat(distanzFeld),
ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null,
kraftstoff: $(`#${praefix}Kraftstoff`).value,
station: $(`#${praefix}Station`).value,
};
}
function vFuelFormular() {
// Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im
// Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind.
return `${neuOffen ? `
` : ""}
`;
}
function vFuel() {
const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a);
/* D5 */
if (!jahre.length) return `
Noch keine Tankvorgänge
Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.
${vFuelFormular()}`;
let h = `
`;
jahre.forEach((j, ji) => {
const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j);
const ersparnisJahr = sum(fj, (f) => f.discount || 0);
/* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge
folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher
konkurrierten drei gleich große rechtsbündige Werte miteinander. */
h += `
`;
const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))];
monate.forEach((m, mi) => {
const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m);
h += `