diff --git a/AGENTS.md b/AGENTS.md
index 0d5a85b..c8bed94 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,7 +34,12 @@ correct (verified via live pointer-event simulation), the real gap was a missing
`overscroll-behavior` on the scroll container letting the browser's own native overscroll compete
with it on real touch devices; same day: the fuel-receipt upload got a proper dialog — drag the PDF
in at the computer, paste it from the clipboard on the phone, file picker still available as a third
-route — see section C). This file is the entry point for every new agent
+route — see section C; a twelfth round the same day removed `HECKKLAPPENSCHLOSS_SENSOR`, fixed a
+half-cut-off image placeholder, gave the Inspektion its own "voraussichtlich am …" forecast, made
+trips fully editable (new `audi_dashboard_fahrt_aktualisieren`, "Neue Fahrt" now offers every field
+the detail page shows), aligned the page title with the tiles on phone width, and closed two
+dead-ends in the bootstrap retry loop behind the reported hanging "Lädt …" screen).
+This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.
@@ -1211,6 +1216,68 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
`companion-app` (Nachfolger-App) brauchte keine Änderung - deren `eur()` rundet ohnehin schon
auf 2 Stellen. Live geprüft: keine `de(..., 3)`-Aufrufe mehr in der Datei, Liste und
Einzelbeleg zeigen "2,07 €/l", "1,99 €/l" usw.
+- [x] Twelfth round (2026-08-17) — six user-reported items, one of them a real dead-end in the
+ bootstrap:
+ (1) **`HECKKLAPPENSCHLOSS_SENSOR` removed for good**, the same treatment Türschloss/
+ Haubenschloss got on 2026-08-16: gone from `einstellungen.py` (attribute deleted, not
+ blanked), from `entitaeten.py`'s Setup catalog, and from `_sicherheitscheck()` in
+ `frontend_veroeffentlichung.py`. `HECKKLAPPE_SENSOR` (the lid itself) stays. Verified live:
+ "Geprüfte Punkte" no longer lists it, the Setup catalog no longer offers it.
+ (2) **Bild-Platzhalter in Einstellungen war halb abgeschnitten** — reproduced by measurement,
+ not by eye: the placeholder rendered 260px tall inside a 132px `overflow:hidden` box. Cause:
+ `bildMitPlatzhalter()` puts the size class (`carfix`) on the box, the `` **and** the
+ placeholder `
`, and the desktop container query said `.carfix,.bildbox.carfix
+ {height:260px}` — the bare selector therefore also hit the placeholder, whose `position:
+ absolute;inset:0` is over-constrained by an explicit height (the height wins, `bottom:0` is
+ ignored). Scoped both rules to `.bildbox.carfix`; image and placeholder fill the box via
+ their own `100%` anyway. Verified: placeholder now measures exactly the box height.
+ (3) **Inspektion bekommt eine "voraussichtlich am …"-Zeile** wie der Ölwechsel, aber auf
+ Wunsch nur mit dem Datum. `oelwechselPrognose()` wurde dafür auf einen gemeinsamen Kern
+ `servicePrognose(stichwort, km, monate)` zurückgeführt; `inspektionPrognose()` nutzt
+ dieselben 30.000 km / 24 Monate wie `intervalle()`/`termine()`. Angezeigt in der
+ Service-Kachel auf "Mein Audi" (auch ohne Fahrzeugmeldung, anders als beim Ölwechsel - dort
+ nennt `terminText()` den generischen Termin schon "voraussichtlich", die Zahl stünde sonst
+ doppelt) und auf der Service-Seite. Live geprüft: beide Stellen zeigen "voraussichtlich am
+ 20.10.2027", Ölwechsel unverändert.
+ (4)+(5) **"Neue Fahrt" hat jetzt alle Felder der Einzelfahrt, und die Einzelfahrt lässt sich
+ bearbeiten.** Neues gemeinsames `fahrtFelder()`/`fahrtFormularWerte()` (Datum, Startzeit,
+ Dauer, Art, Start-/Zielort, Start-/Endkilometer, Distanz) nach demselben Muster wie
+ `tankFelder()`/`tankFormularWerte()`, benutzt vom Erfassungsformular und vom neuen
+ "Werte bearbeiten" auf der Einzelfahrt-Seite (`fahrtBearbeitenOffen`, Vorbild `vFill()`).
+ Backend: `audi_dashboard_fahrt_manuell_anlegen` um dieselben Felder erweitert, neuer Service
+ `audi_dashboard_fahrt_aktualisieren`. Zwei Punkte, die dabei zählen: von Hand gesetzte Felder
+ landen in `edited_fields`, damit das Kilometerstand-Screening (§7.2) sie nicht später
+ überschreibt - und weil `_datensatz_aktualisieren()` genau diese Liste schützt, hätte ein
+ zweites Bearbeiten sonst nichts mehr bewirkt; dafür gibt es jetzt `profil.fahrt_bearbeiten()`
+ (`schutz=False`), der Schutz gilt gegen die Automatik, nicht gegen den Menschen. Fehlt die
+ Distanz, ergibt sie sich aus den beiden Kilometerständen (`km_quelle: "manuell"`, Status
+ "vollständig"). Gleich mitgezogen: die Art-Pille auf der Einzelfahrt schaltete bisher nur
+ lokal um und war nach dem nächsten Backend-Push wieder weg - sie ruft jetzt denselben
+ Service. **Merkposten pyscript:** Generator-Ausdrücke gibt es dort nicht
+ (`NotImplementedError: not implemented ast ast_generatorexp`, an `next((f for f in …), None)`
+ real gesehen) - Listen-Abstraktionen dagegen schon. Live end-to-end geprüft: anlegen,
+ bearbeiten, erneut bearbeiten, jeweils in `fahrten.jsonl` nachgesehen und nach Reload wieder
+ im Formular.
+ (6) **Titelzeile stand auf dem Handy 34px rechts von den Kacheln.** Derselbe Befund wie im
+ Design-Audit 2026-08-13, dort aber nur in der Desktop-Container-Query behoben: `.back` ist
+ auf Wurzelseiten unsichtbar (`opacity:0`), belegt aber Breite plus Gap. Die Regel
+ `.back:not(.on){width:0;…}` gilt jetzt auf allen Breiten und hebt per `margin-right:-4px`
+ auch den Gap auf; `.topbar` bekam links 16px statt 20px, passend zu `--seitenrand` von
+ `main#view`. Gemessen: Titel und Kachel beginnen auf dem Handy beide bei 16px, auf dem
+ Desktop beide bei 564px (dort blieben vorher 4px Rest). Detailseiten (`.back.on`) unberührt.
+- [x] Ladebildschirm: der Takt darf nicht abreißen (2026-08-17) — Nutzerbericht "nach dem Neuladen
+ dreht sich nur 'Lädt …', erst ein Klick auf einen Menüpunkt zeigt die App". Beim Lesen des
+ Bootstraps fanden sich zwei echte Sackgassen, die genau dieses Verhalten erklären:
+ `nachladeAnstossen()` stieg bei `!HASS || !HASS.callWS` **ohne** neuen Timer aus, und
+ `datenLaden()` bei `!HASS` ebenso - danach lief nichts mehr von selbst, und der einzige
+ Ausweg war ein zufälliger hass-Push oder ein Menüklick (`go()` ruft `datenLaden()` mit auf,
+ genau die Abmilderung vom 2026-08-16). Beide planen jetzt die nächste Runde, statt
+ auszusteigen; `disconnectedCallback()` stoppt den Timer dafür sauber, `connectedCallback()`
+ stößt ihn wieder an. **Nicht reproduziert:** weder ein einfacher Reload noch ein
+ Container-Neustart mit sofortigem Reload haben den Hänger in `audi_ha_test` gezeigt (die App
+ war jedes Mal binnen ~2s da) - die Sackgassen sind im Code belegt, dass sie *die* Ursache
+ beim Nutzer waren, ist damit aber nicht bewiesen. Tritt es erneut auf: Browser-Konsole
+ erfragen, bevor weiter geraten wird.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
diff --git a/homeassistant/installationspaket/pyscript/fahrterkennung.py b/homeassistant/installationspaket/pyscript/fahrterkennung.py
index b445a4c..358cb45 100644
--- a/homeassistant/installationspaket/pyscript/fahrterkennung.py
+++ b/homeassistant/installationspaket/pyscript/fahrterkennung.py
@@ -122,13 +122,43 @@ def _fahrt_beenden(start_ts, ende_ts):
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
+def _handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz):
+ """Die von Hand befüllbaren Felder einer Fahrt auf ihre Datensatz-Namen
+ abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel
+ zusätzlich nach edited_fields - das schützt sie vor dem automatischen
+ Kilometerstand-Screening (§7.2), das sonst später darüberschreiben würde."""
+ werte = {
+ "start_address": start_ort or None,
+ "end_address": ziel_ort or None,
+ "odo_start": odo_start,
+ "odo_end": odo_end,
+ "distance_km": distanz,
+ }
+ return {k: v for k, v in werte.items() if v is not None}
+
+
+def _strecke_ergaenzen(felder):
+ """Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
+ dieselbe Rechnung wie im Screening (fahrtabschluss_logik)."""
+ if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
+ felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
+ return felder
+
+
@service
-def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"):
+def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat",
+ start_ort=None, ziel_ort=None,
+ odo_start=None, odo_end=None, distanz=None):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
- automatischen Zündungs-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen."""
+ automatischen Zündungs-Erkennung. Dieselben Felder wie beim Bearbeiten
+ einer bestehenden Fahrt (siehe audi_dashboard_fahrt_aktualisieren); alles
+ außer Start- und Endzeitpunkt ist optional und bleibt leer, wenn es das
+ Screening später selbst ergänzen soll. Aufruf als
+ pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds())
+ hand = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
@@ -152,11 +182,54 @@ def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat
"status": "offen",
"edited_fields": [],
}
+ fahrt.update(hand)
+ fahrt["edited_fields"] = sorted(hand)
+ if fahrt["distance_km"] is not None:
+ fahrt["km_quelle"] = "manuell"
+ fahrt["status"] = "vollständig"
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
+@service
+def audi_dashboard_fahrt_aktualisieren(trip_id=None, ts_start=None, ts_end=None, art=None,
+ start_ort=None, ziel_ort=None,
+ odo_start=None, odo_end=None, distanz=None):
+ """Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
+ automatisch erkannt oder von Hand angelegt wurde. Aufruf als
+ pyscript.audi_dashboard_fahrt_aktualisieren."""
+ if not trip_id:
+ log.warning("audi_dashboard: fahrt_aktualisieren ohne trip_id aufgerufen")
+ return
+ aenderungen = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
+ if art:
+ aenderungen["art"] = art
+ if ts_start and ts_end:
+ start_ts = datetime.datetime.fromisoformat(ts_start)
+ ende_ts = datetime.datetime.fromisoformat(ts_end)
+ aenderungen["ts_start"] = start_ts.isoformat()
+ aenderungen["ts_end"] = ende_ts.isoformat()
+ aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
+ if aenderungen.get("distance_km") is not None:
+ aenderungen["km_quelle"] = "manuell"
+ aenderungen["status"] = "vollständig"
+ # Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
+ # damit ein späteres Screening sie nicht wieder überschreibt.
+ # Bewusst eine Listen-Abstraktion statt next(): pyscript kann
+ # keine Generator-Ausdrücke auswerten ("not implemented ast
+ # ast_generatorexp"), Listen-Abstraktionen dagegen schon.
+ passend = [f for f in profil.fahrten_lesen() if f.get("trip_id") == trip_id]
+ handfelder = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
+ bisher = set(passend[0].get("edited_fields", [])) if passend else set()
+ aenderungen["edited_fields"] = sorted(bisher | (handfelder & set(aenderungen)))
+ if profil.fahrt_bearbeiten(trip_id, aenderungen):
+ frontend_veroeffentlichung.fahrten_veroeffentlichen()
+ log.info(f"audi_dashboard: Fahrt {trip_id} aktualisiert")
+ else:
+ log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
+
+
@service
def audi_dashboard_fahrt_loeschen(trip_id=None):
"""Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche,
diff --git a/homeassistant/installationspaket/pyscript/modules/einstellungen.py b/homeassistant/installationspaket/pyscript/modules/einstellungen.py
index ba43fc0..4fe37d4 100644
--- a/homeassistant/installationspaket/pyscript/modules/einstellungen.py
+++ b/homeassistant/installationspaket/pyscript/modules/einstellungen.py
@@ -67,7 +67,6 @@ FENSTER_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
-HECKKLAPPENSCHLOSS_SENSOR = ""
HAUBE_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
diff --git a/homeassistant/installationspaket/pyscript/modules/entitaeten.py b/homeassistant/installationspaket/pyscript/modules/entitaeten.py
index d38f16d..23bc62d 100644
--- a/homeassistant/installationspaket/pyscript/modules/entitaeten.py
+++ b/homeassistant/installationspaket/pyscript/modules/entitaeten.py
@@ -85,10 +85,6 @@ FELDER = [
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
- {"key": "HECKKLAPPENSCHLOSS_SENSOR", "label": "Heckklappenschloss", "gruppe": "sicherheit",
- "hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
- "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
- "stichworte": ["heckklappe", "tailgate", "schloss", "lock"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
diff --git a/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py
index dd0cff5..7e6ceb2 100644
--- a/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py
+++ b/homeassistant/installationspaket/pyscript/modules/frontend_veroeffentlichung.py
@@ -138,7 +138,6 @@ def _sicherheitscheck():
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
- ("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
]:
w = zustand_oder_none(sensor)
diff --git a/homeassistant/installationspaket/pyscript/modules/profil.py b/homeassistant/installationspaket/pyscript/modules/profil.py
index 4631d86..23bb3f2 100644
--- a/homeassistant/installationspaket/pyscript/modules/profil.py
+++ b/homeassistant/installationspaket/pyscript/modules/profil.py
@@ -138,16 +138,20 @@ def offene_fahrten():
return sorted(offen, key=lambda f: f.get("ts_start", ""))
-def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben):
+def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True):
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
schreibt den gesamten Bestand neu. Manuell geänderte Felder
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
- funktionieren nach demselben Muster."""
+ funktionieren nach demselben Muster.
+
+ schutz=False hebt genau diese Sperre auf - nötig für Eingaben aus der
+ Oberfläche: edited_fields schützt gegen die automatische Ergänzung, nicht
+ gegen den Menschen, der das Feld gerade selbst korrigiert."""
geaendert = False
for d in zeilen:
if d.get(id_feld) == id_wert:
- geschuetzt = set(d.get("edited_fields", []))
+ geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
for feld, wert in aenderungen.items():
if feld not in geschuetzt:
d[feld] = wert
@@ -162,6 +166,13 @@ def fahrt_aktualisieren(trip_id, aenderungen):
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
+def fahrt_bearbeiten(trip_id, aenderungen):
+ """Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: eine
+ von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld schon einmal
+ von Hand gesetzt wurde (siehe schutz-Parameter oben)."""
+ return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False)
+
+
def fahrt_loeschen(trip_id):
fahrten = fahrten_lesen()
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
diff --git a/homeassistant/installationspaket/www/audi-dashboard-app.js b/homeassistant/installationspaket/www/audi-dashboard-app.js
index 986a91e..4974506 100644
--- a/homeassistant/installationspaket/www/audi-dashboard-app.js
+++ b/homeassistant/installationspaket/www/audi-dashboard-app.js
@@ -1123,24 +1123,24 @@ function fahrzeugMeldungRoh(art) {
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.
+/* App-eigene Service-Prognose, eigenständig statt über die allgemeine
+ tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie
+ den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit
+ einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum
+ der letzten tatsächlich durchgeführten Arbeit dieser Art.
- Logik (wie vorgegeben): km und Tage seit 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() {
+ Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle
+ Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum
+ eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das
+ Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither
+ wenig gefahren wurde.
+
+ Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden
+ Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */
+function servicePrognose(stichwort, intervallKm, intervallMonate) {
if (!CAR.odoBekannt) return null;
- const letzter = letzterEintrag("öl");
+ const letzter = letzterEintrag(stichwort);
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);
@@ -1151,13 +1151,13 @@ function oelwechselPrognose() {
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.
+ // Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B.
// direkt danach) - ohne Rate keine seriöse Km-Prognose, also das
// Zeitlimit als einzig belastbare Grenze zeigen.
datum = kappDatum;
} else {
- const kmProTagSeitOel = kmSeit / tageSeit;
- const tageBisZiel = restKm / kmProTagSeitOel;
+ const kmProTagSeither = kmSeit / tageSeit;
+ const tageBisZiel = restKm / kmProTagSeither;
const berechnet = new Date(heute().getTime() + tageBisZiel * dTag);
datum = berechnet < kappDatum ? berechnet : kappDatum;
}
@@ -1165,6 +1165,27 @@ function oelwechselPrognose() {
return { km: zielKm, restKm, datum };
}
+function oelwechselPrognose() {
+ const oel = CAR.oel;
+ return servicePrognose("öl",
+ oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm),
+ oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate));
+}
+/* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine().
+ Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die
+ Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */
+function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); }
+
+/* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und
+ Service-Seite identisch aufgebaut. Leer, solange kein passender
+ Servicebucheintrag vorliegt. */
+function prognoseZeile(art) {
+ const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null;
+ if (!p) return "";
+ const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : "";
+ return ` ${km}voraussichtlich am ${dedat(p.datum)}`;
+}
+
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
@@ -1318,22 +1339,25 @@ function vAudi() {
// (Sensor nicht verfügbar), fällt sie auf dieselbe App-eigene
// Servicebuch-Schätzung zurück wie Hauptuntersuchung.
const fm = t.art === "Hauptuntersuchung" ? { ts: null, km: null } : fahrzeugMeldungRoh(t.art);
+ // Zusätzlich zur realen Meldung klein die App-eigene Prognose
+ // (Servicebuch + tatsächliche Fahrleistung seit der letzten Arbeit,
+ // siehe servicePrognose()). Steht links unter dem Kachel-Titel statt
+ // rechts bei "am"/"in", die beide gleichwertig (gleiche Schriftgröße
+ // wie "am") untereinander in der rechten Spalte stehen. Die Inspektion
+ // bekommt sie auf Wunsch auch ohne Fahrzeugmeldung und ohne den
+ // km-Anteil - beim Ölwechsel stünde die Zahl dort doppelt, weil
+ // terminText() den generischen Termin schon "voraussichtlich" nennt.
+ const eigen = prognoseZeile(t.art);
if (fm.ts == null && fm.km == null) {
const x = terminText(t);
return `
-
${t.art}
${x.haupt}${x.neben ? `${x.neben}` : ""}
`;
+
${t.art}${t.art === "Inspektion" ? eigen : ""}
+
${x.haupt}${x.neben ? `${x.neben}` : ""}
`;
}
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 `
-
${t.art}${eigen ? ` ${de(eigen.restKm)} km, voraussichtlich am ${dedat(eigen.datum)}` : ""}
+
${t.art}${eigen}
${haupt}${neben ? `${neben}` : ""}
`;
}).join("")}
@@ -1628,11 +1652,12 @@ function vService() {
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.
+ // Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
+ // eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
+ // Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
+ // dem Datum. Hauptuntersuchung bleibt bewusst ohne.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
+ const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
return `
`; }).join("")}
@@ -2440,17 +2467,69 @@ function vReifen() {
${satz("winter")}`;
}
+/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
+ Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander-
+ laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die
+ Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch,
+ Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet
+ das Backend ab. */
+const FAHRT_ARTEN = ["privat", "arbeitsweg"];
+
+/* Datum für ein in Ortszeit. Bewusst nicht über
+ toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
+ den Vortag. */
+function datumsfeld(d) {
+ const zwei = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
+}
+function zeitfeld(d) {
+ const zwei = (n) => String(n).padStart(2, "0");
+ return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
+}
+
+function fahrtFelder(praefix, t) {
+ const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
+ const dauer = t && t.duration_s != null ? Math.round(t.duration_s / 60) : "";
+ const art = t && t.art ? t.art : FAHRT_ARTEN[0];
+ const wert = (v) => (v == null ? "" : v);
+ return `
+
+
+
+ min
+
+
+
+
+
+
+
+ km
`;
+}
+
+function fahrtFormularWerte(praefix) {
+ const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
+ const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
+ const dauer = parseInt($(`#${praefix}Dauer`).value, 10) || 0;
+ const tsStart = new Date(`${$(`#${praefix}Datum`).value}T${$(`#${praefix}Start`).value}:00`);
+ return {
+ ts_start: tsStart.toISOString(),
+ ts_end: new Date(tsStart.getTime() + dauer * 60000).toISOString(),
+ art: $(`#${praefix}Art`).value,
+ start_ort: text("Startort"), ziel_ort: text("Zielort"),
+ odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"),
+ };
+}
+
function vTripsFormular() {
// eigene Funktion, damit sowohl der volle als auch der Leer-Zustand
// (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe
// Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf
// wechselte zu "Abbrechen", aber es erschienen keine Felder.
return `${fahrtOffen ? `
Neue Fahrt
-
-
-
-
` : ""}
- `;
+ ${fahrtFelder("neuFahrt", null)}
+ ` : ""}
+ `;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
@@ -2491,6 +2570,16 @@ function vTrips() {
function vTrip(id) {
const t = TRIPS.find((x) => x.trip_id === id);
if (!t) return `
+
+
+
`;
}
@@ -2637,6 +2728,7 @@ function vStat() {
let neuOffen = false;
let fahrtOffen = false;
let tankBearbeitenOffen = false;
+let fahrtBearbeitenOffen = false;
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
let belegEntwurf = null;
@@ -3324,6 +3416,9 @@ const go = (name, id) => {
entwurfIndex = null;
}
sheet = null;
+ // Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es
+ // geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern.
+ fahrtBearbeitenOffen = false;
/* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt
den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */
if (istTab(name)) routeStack = [];
@@ -3333,6 +3428,7 @@ const go = (name, id) => {
};
function zurueck() {
sheet = null;
+ fahrtBearbeitenOffen = false;
const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" };
navRichtung = "raus";
route = ziel; render();
@@ -3821,12 +3917,18 @@ function ereignisseVerdrahten() {
}
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" });
+ serviceRufen("audi_dashboard_fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt"));
fahrtOffen = false; render(); return;
}
+ if (e.target.closest("[data-fahrtbearbeiten]")) {
+ fahrtBearbeitenOffen = !fahrtBearbeitenOffen; render(); return;
+ }
+ const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]");
+ if (fahrtAkt) {
+ serviceRufen("audi_dashboard_fahrt_aktualisieren",
+ { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") });
+ fahrtBearbeitenOffen = false; render(); return;
+ }
if (e.target.closest("[data-tankspeichern]")) {
const w = tankFormularWerte("neuTank");
if (w.liter > 0 && w.kosten > 0) {
@@ -3987,7 +4089,14 @@ function ereignisseVerdrahten() {
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; }
+ if (art) {
+ const tr = TRIPS.find((x) => x.trip_id === art.dataset.art);
+ tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg";
+ // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem
+ // naechsten Backend-Push wieder weg - jetzt wandert es mit.
+ serviceRufen("audi_dashboard_fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art });
+ render(); return;
+ }
// Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü-
// DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt).
if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; }
@@ -4209,8 +4318,15 @@ function belegErgebnisVerarbeiten(daten) {
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). */
+ wird (disconnectedCallback stoppt den Timer, connectedCallback stößt ihn
+ über datenLaden() wieder an).
+
+ 2026-08-17: Der Takt darf an keiner Stelle abreißen. Vorher stieg sowohl
+ nachladeAnstossen() (bei fehlendem HASS/callWS) als auch datenLaden() (bei
+ fehlendem HASS) still aus, ohne eine nächste Runde zu planen - danach half
+ nur noch ein zufälliger hass-Push oder ein Klick ins Menü (go() ruft
+ datenLaden() mit auf). Genau das war das gemeldete "Lädt … bis man auf
+ einen Menüpunkt klickt". */
const NACHGELADEN = {};
let nachladeTimer = null;
let nachladeVersuche = 0;
@@ -4219,7 +4335,7 @@ const NACHLADE_LANGSAM_MS = 10000;
const NACHLADE_SCHNELLE_VERSUCHE = 15;
function nachladeAnstossen() {
- if (nachladeTimer || !HASS || !HASS.callWS) return;
+ if (nachladeTimer) return;
nachladeVersuche++;
// Erster Versuch fast sofort statt erst nach vollem Intervall - der
// Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist
@@ -4232,26 +4348,40 @@ function nachladeAnstossen() {
: NACHLADE_LANGSAM_MS;
nachladeTimer = setTimeout(async () => {
nachladeTimer = null;
- try {
- const states = await HASS.callWS({ type: "get_states" });
- const finden = (id) => states.find((s) => s.entity_id === id);
- NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil;
- NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten;
- NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank;
- NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status;
- NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt;
- // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
- // bis zufaellig ein Push kommt - siehe setupBereit().
- NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten;
- } catch (e) {
- // wird beim naechsten Versuch erneut probiert
+ // HASS kann hier noch fehlen: panel_custom erzeugt das Element und setzt
+ // hass nicht immer in dieser Reihenfolge. Frueher brach die Schleife in so
+ // einem Fall oben ganz ab (Bedingung !HASS || !HASS.callWS) und wurde nie
+ // wieder angestossen - genau der dauerhaft haengende "Lädt …"-Bildschirm.
+ // Jetzt wird die Runde nur uebersprungen, der Takt laeuft weiter.
+ if (HASS && HASS.callWS) {
+ try {
+ const states = await HASS.callWS({ type: "get_states" });
+ const finden = (id) => states.find((s) => s.entity_id === id);
+ NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil;
+ NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten;
+ NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank;
+ NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status;
+ NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt;
+ // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
+ // bis zufaellig ein Push kommt - siehe setupBereit().
+ NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten;
+ } catch (e) {
+ // wird beim naechsten Versuch erneut probiert
+ }
}
datenLaden(true);
+ // Rueckversicherung: datenLaden() stoesst die naechste Runde selbst an,
+ // aber nur, wenn es nicht vorher aussteigt. Der Takt darf an keiner
+ // Stelle abreissen, solange noch keine Daten da sind.
+ if (!DATEN_GELADEN) nachladeAnstossen();
}, verzoegerung);
}
async function datenLaden(erzwingen) {
- if (!HASS) return;
+ if (!HASS) {
+ if (!DATEN_GELADEN) nachladeAnstossen();
+ 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;
@@ -4355,6 +4485,9 @@ class AudiDashboardPanel extends HTMLElement {
}
disconnectedCallback() {
+ // Nachlade-Takt anhalten, solange das Panel nicht im Dokument haengt -
+ // connectedCallback() stoesst ihn ueber datenLaden() wieder an.
+ if (nachladeTimer) { clearTimeout(nachladeTimer); nachladeTimer = null; }
// Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels
// (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
// window-Resize-Horcher und laufenden Kachelabrufen am Leben.
diff --git a/homeassistant/installationspaket/www/audi-dashboard-ios.css b/homeassistant/installationspaket/www/audi-dashboard-ios.css
index 5bac293..bf00064 100644
--- a/homeassistant/installationspaket/www/audi-dashboard-ios.css
+++ b/homeassistant/installationspaket/www/audi-dashboard-ios.css
@@ -68,11 +68,21 @@ html,body{height:100%}
/* ---------------- Navigationsleiste ---------------- */
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
-.topbar{align-items:center;gap:4px;padding:56px 20px 4px}
+/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
+ exakt auf der Kante der Kacheln darunter sitzt. */
+.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
.head{display:flex;flex-direction:column}
.eyebrow{display:none}
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
+/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
+ auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
+ ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
+ nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
+ zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
+ (.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
+ bedienbarer Knopf. */
+.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
wie die Kopfzeile selbst. */
@@ -264,12 +274,6 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
- /* Design-Audit 2026-08-13: .back ist auch inaktiv (opacity:0) noch ein
- 44px breites Flex-Element plus Gap - auf Wurzelseiten ohne Zurueck-Pfeil
- schob das die Kopfzeile (.head/.title) sichtbar weiter nach rechts als
- main#view darunter beginnt. Auf Detailseiten (.back.on) bleibt der Platz
- bewusst reserviert, das ist dort ein echter, bedienbarer Knopf. */
- .back:not(.on){width:0;min-width:0;margin:0;overflow:hidden}
.phone:has(.back.on) .head{align-items:flex-start}
.phone:has(.back.on) .title{font-size:26px;text-align:left}
.title{font-size:34px}
@@ -304,8 +308,14 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
.rings{display:none !important}
.duo{grid-template-columns:repeat(3,minmax(0,1fr))}
- .carfix,.bildbox.carfix{height:260px}
- .carfix.klein,.bildbox.carfix.klein{height:220px}
+ /* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
+ Klasse haengt auch am und am Platzhalter-
(siehe
+ bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
+ eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
+ einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
+ abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
+ .bildbox.carfix{height:260px}
+ .bildbox.carfix.klein{height:220px}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
diff --git a/homeassistant/installationspaket/www/audi-dashboard-version.json b/homeassistant/installationspaket/www/audi-dashboard-version.json
index 0d3db61..dfd7c6b 100644
--- a/homeassistant/installationspaket/www/audi-dashboard-version.json
+++ b/homeassistant/installationspaket/www/audi-dashboard-version.json
@@ -1 +1 @@
-{"version": 1786920185}
+{"version": 1787000000}
diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py
index b445a4c..358cb45 100644
--- a/homeassistant/pyscript/fahrterkennung.py
+++ b/homeassistant/pyscript/fahrterkennung.py
@@ -122,13 +122,43 @@ def _fahrt_beenden(start_ts, ende_ts):
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
+def _handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz):
+ """Die von Hand befüllbaren Felder einer Fahrt auf ihre Datensatz-Namen
+ abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel
+ zusätzlich nach edited_fields - das schützt sie vor dem automatischen
+ Kilometerstand-Screening (§7.2), das sonst später darüberschreiben würde."""
+ werte = {
+ "start_address": start_ort or None,
+ "end_address": ziel_ort or None,
+ "odo_start": odo_start,
+ "odo_end": odo_end,
+ "distance_km": distanz,
+ }
+ return {k: v for k, v in werte.items() if v is not None}
+
+
+def _strecke_ergaenzen(felder):
+ """Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
+ dieselbe Rechnung wie im Screening (fahrtabschluss_logik)."""
+ if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
+ felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
+ return felder
+
+
@service
-def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"):
+def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat",
+ start_ort=None, ziel_ort=None,
+ odo_start=None, odo_end=None, distanz=None):
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
- automatischen Zündungs-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen."""
+ automatischen Zündungs-Erkennung. Dieselben Felder wie beim Bearbeiten
+ einer bestehenden Fahrt (siehe audi_dashboard_fahrt_aktualisieren); alles
+ außer Start- und Endzeitpunkt ist optional und bleibt leer, wenn es das
+ Screening später selbst ergänzen soll. Aufruf als
+ pyscript.audi_dashboard_fahrt_manuell_anlegen."""
start_ts = datetime.datetime.fromisoformat(ts_start)
ende_ts = datetime.datetime.fromisoformat(ts_end)
dauer_s = int((ende_ts - start_ts).total_seconds())
+ hand = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
fahrt = {
"trip_id": profil.neue_id("t"),
"ts_start": start_ts.isoformat(),
@@ -152,11 +182,54 @@ def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat
"status": "offen",
"edited_fields": [],
}
+ fahrt.update(hand)
+ fahrt["edited_fields"] = sorted(hand)
+ if fahrt["distance_km"] is not None:
+ fahrt["km_quelle"] = "manuell"
+ fahrt["status"] = "vollständig"
profil.fahrt_anhaengen(fahrt)
frontend_veroeffentlichung.fahrten_veroeffentlichen()
fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR)
+@service
+def audi_dashboard_fahrt_aktualisieren(trip_id=None, ts_start=None, ts_end=None, art=None,
+ start_ort=None, ziel_ort=None,
+ odo_start=None, odo_end=None, distanz=None):
+ """Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
+ automatisch erkannt oder von Hand angelegt wurde. Aufruf als
+ pyscript.audi_dashboard_fahrt_aktualisieren."""
+ if not trip_id:
+ log.warning("audi_dashboard: fahrt_aktualisieren ohne trip_id aufgerufen")
+ return
+ aenderungen = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz))
+ if art:
+ aenderungen["art"] = art
+ if ts_start and ts_end:
+ start_ts = datetime.datetime.fromisoformat(ts_start)
+ ende_ts = datetime.datetime.fromisoformat(ts_end)
+ aenderungen["ts_start"] = start_ts.isoformat()
+ aenderungen["ts_end"] = ende_ts.isoformat()
+ aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
+ if aenderungen.get("distance_km") is not None:
+ aenderungen["km_quelle"] = "manuell"
+ aenderungen["status"] = "vollständig"
+ # Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
+ # damit ein späteres Screening sie nicht wieder überschreibt.
+ # Bewusst eine Listen-Abstraktion statt next(): pyscript kann
+ # keine Generator-Ausdrücke auswerten ("not implemented ast
+ # ast_generatorexp"), Listen-Abstraktionen dagegen schon.
+ passend = [f for f in profil.fahrten_lesen() if f.get("trip_id") == trip_id]
+ handfelder = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
+ bisher = set(passend[0].get("edited_fields", [])) if passend else set()
+ aenderungen["edited_fields"] = sorted(bisher | (handfelder & set(aenderungen)))
+ if profil.fahrt_bearbeiten(trip_id, aenderungen):
+ frontend_veroeffentlichung.fahrten_veroeffentlichen()
+ log.info(f"audi_dashboard: Fahrt {trip_id} aktualisiert")
+ else:
+ log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden")
+
+
@service
def audi_dashboard_fahrt_loeschen(trip_id=None):
"""Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche,
diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py
index ba43fc0..4fe37d4 100644
--- a/homeassistant/pyscript/modules/einstellungen.py
+++ b/homeassistant/pyscript/modules/einstellungen.py
@@ -67,7 +67,6 @@ FENSTER_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
-HECKKLAPPENSCHLOSS_SENSOR = ""
HAUBE_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
diff --git a/homeassistant/pyscript/modules/entitaeten.py b/homeassistant/pyscript/modules/entitaeten.py
index d38f16d..23bc62d 100644
--- a/homeassistant/pyscript/modules/entitaeten.py
+++ b/homeassistant/pyscript/modules/entitaeten.py
@@ -85,10 +85,6 @@ FELDER = [
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
- {"key": "HECKKLAPPENSCHLOSS_SENSOR", "label": "Heckklappenschloss", "gruppe": "sicherheit",
- "hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
- "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
- "stichworte": ["heckklappe", "tailgate", "schloss", "lock"]},
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
index dd0cff5..7e6ceb2 100644
--- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
+++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
@@ -138,7 +138,6 @@ def _sicherheitscheck():
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
- ("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
]:
w = zustand_oder_none(sensor)
diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py
index 4631d86..23bb3f2 100644
--- a/homeassistant/pyscript/modules/profil.py
+++ b/homeassistant/pyscript/modules/profil.py
@@ -138,16 +138,20 @@ def offene_fahrten():
return sorted(offen, key=lambda f: f.get("ts_start", ""))
-def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben):
+def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True):
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und
schreibt den gesamten Bestand neu. Manuell geänderte Felder
(edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben.
Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive
- funktionieren nach demselben Muster."""
+ funktionieren nach demselben Muster.
+
+ schutz=False hebt genau diese Sperre auf - nötig für Eingaben aus der
+ Oberfläche: edited_fields schützt gegen die automatische Ergänzung, nicht
+ gegen den Menschen, der das Feld gerade selbst korrigiert."""
geaendert = False
for d in zeilen:
if d.get(id_feld) == id_wert:
- geschuetzt = set(d.get("edited_fields", []))
+ geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
for feld, wert in aenderungen.items():
if feld not in geschuetzt:
d[feld] = wert
@@ -162,6 +166,13 @@ def fahrt_aktualisieren(trip_id, aenderungen):
return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben)
+def fahrt_bearbeiten(trip_id, aenderungen):
+ """Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: eine
+ von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld schon einmal
+ von Hand gesetzt wurde (siehe schutz-Parameter oben)."""
+ return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False)
+
+
def fahrt_loeschen(trip_id):
fahrten = fahrten_lesen()
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js
index 986a91e..4974506 100644
--- a/homeassistant/www/audi-dashboard-app.js
+++ b/homeassistant/www/audi-dashboard-app.js
@@ -1123,24 +1123,24 @@ function fahrzeugMeldungRoh(art) {
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.
+/* App-eigene Service-Prognose, eigenständig statt über die allgemeine
+ tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie
+ den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit
+ einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum
+ der letzten tatsächlich durchgeführten Arbeit dieser Art.
- Logik (wie vorgegeben): km und Tage seit 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() {
+ Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle
+ Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum
+ eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das
+ Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither
+ wenig gefahren wurde.
+
+ Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden
+ Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */
+function servicePrognose(stichwort, intervallKm, intervallMonate) {
if (!CAR.odoBekannt) return null;
- const letzter = letzterEintrag("öl");
+ const letzter = letzterEintrag(stichwort);
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);
@@ -1151,13 +1151,13 @@ function oelwechselPrognose() {
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.
+ // Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B.
// direkt danach) - ohne Rate keine seriöse Km-Prognose, also das
// Zeitlimit als einzig belastbare Grenze zeigen.
datum = kappDatum;
} else {
- const kmProTagSeitOel = kmSeit / tageSeit;
- const tageBisZiel = restKm / kmProTagSeitOel;
+ const kmProTagSeither = kmSeit / tageSeit;
+ const tageBisZiel = restKm / kmProTagSeither;
const berechnet = new Date(heute().getTime() + tageBisZiel * dTag);
datum = berechnet < kappDatum ? berechnet : kappDatum;
}
@@ -1165,6 +1165,27 @@ function oelwechselPrognose() {
return { km: zielKm, restKm, datum };
}
+function oelwechselPrognose() {
+ const oel = CAR.oel;
+ return servicePrognose("öl",
+ oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm),
+ oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate));
+}
+/* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine().
+ Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die
+ Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */
+function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); }
+
+/* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und
+ Service-Seite identisch aufgebaut. Leer, solange kein passender
+ Servicebucheintrag vorliegt. */
+function prognoseZeile(art) {
+ const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null;
+ if (!p) return "";
+ const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : "";
+ return ` ${km}voraussichtlich am ${dedat(p.datum)}`;
+}
+
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
@@ -1318,22 +1339,25 @@ function vAudi() {
// (Sensor nicht verfügbar), fällt sie auf dieselbe App-eigene
// Servicebuch-Schätzung zurück wie Hauptuntersuchung.
const fm = t.art === "Hauptuntersuchung" ? { ts: null, km: null } : fahrzeugMeldungRoh(t.art);
+ // Zusätzlich zur realen Meldung klein die App-eigene Prognose
+ // (Servicebuch + tatsächliche Fahrleistung seit der letzten Arbeit,
+ // siehe servicePrognose()). Steht links unter dem Kachel-Titel statt
+ // rechts bei "am"/"in", die beide gleichwertig (gleiche Schriftgröße
+ // wie "am") untereinander in der rechten Spalte stehen. Die Inspektion
+ // bekommt sie auf Wunsch auch ohne Fahrzeugmeldung und ohne den
+ // km-Anteil - beim Ölwechsel stünde die Zahl dort doppelt, weil
+ // terminText() den generischen Termin schon "voraussichtlich" nennt.
+ const eigen = prognoseZeile(t.art);
if (fm.ts == null && fm.km == null) {
const x = terminText(t);
return `
-
${t.art}
${x.haupt}${x.neben ? `${x.neben}` : ""}
`;
+
${t.art}${t.art === "Inspektion" ? eigen : ""}
+
${x.haupt}${x.neben ? `${x.neben}` : ""}
`;
}
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 `
-
${t.art}${eigen ? ` ${de(eigen.restKm)} km, voraussichtlich am ${dedat(eigen.datum)}` : ""}
+
${t.art}${eigen}
${haupt}${neben ? `${neben}` : ""}
`;
}).join("")}
@@ -1628,11 +1652,12 @@ function vService() {
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.
+ // Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
+ // eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
+ // Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
+ // dem Datum. Hauptuntersuchung bleibt bewusst ohne.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
+ const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
return `
`; }).join("")}
@@ -2440,17 +2467,69 @@ function vReifen() {
${satz("winter")}`;
}
+/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
+ Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander-
+ laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die
+ Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch,
+ Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet
+ das Backend ab. */
+const FAHRT_ARTEN = ["privat", "arbeitsweg"];
+
+/* Datum für ein in Ortszeit. Bewusst nicht über
+ toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
+ den Vortag. */
+function datumsfeld(d) {
+ const zwei = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
+}
+function zeitfeld(d) {
+ const zwei = (n) => String(n).padStart(2, "0");
+ return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
+}
+
+function fahrtFelder(praefix, t) {
+ const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
+ const dauer = t && t.duration_s != null ? Math.round(t.duration_s / 60) : "";
+ const art = t && t.art ? t.art : FAHRT_ARTEN[0];
+ const wert = (v) => (v == null ? "" : v);
+ return `
+
+
+
+ min
+
+
+
+
+
+
+
+ km
`;
+}
+
+function fahrtFormularWerte(praefix) {
+ const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
+ const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
+ const dauer = parseInt($(`#${praefix}Dauer`).value, 10) || 0;
+ const tsStart = new Date(`${$(`#${praefix}Datum`).value}T${$(`#${praefix}Start`).value}:00`);
+ return {
+ ts_start: tsStart.toISOString(),
+ ts_end: new Date(tsStart.getTime() + dauer * 60000).toISOString(),
+ art: $(`#${praefix}Art`).value,
+ start_ort: text("Startort"), ziel_ort: text("Zielort"),
+ odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"),
+ };
+}
+
function vTripsFormular() {
// eigene Funktion, damit sowohl der volle als auch der Leer-Zustand
// (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe
// Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf
// wechselte zu "Abbrechen", aber es erschienen keine Felder.
return `${fahrtOffen ? `
Neue Fahrt
-
-
-
-
` : ""}
- `;
+ ${fahrtFelder("neuFahrt", null)}
+ ` : ""}
+ `;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
@@ -2491,6 +2570,16 @@ function vTrips() {
function vTrip(id) {
const t = TRIPS.find((x) => x.trip_id === id);
if (!t) return `
+
+
+
`;
}
@@ -2637,6 +2728,7 @@ function vStat() {
let neuOffen = false;
let fahrtOffen = false;
let tankBearbeitenOffen = false;
+let fahrtBearbeitenOffen = false;
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
let belegEntwurf = null;
@@ -3324,6 +3416,9 @@ const go = (name, id) => {
entwurfIndex = null;
}
sheet = null;
+ // Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es
+ // geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern.
+ fahrtBearbeitenOffen = false;
/* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt
den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */
if (istTab(name)) routeStack = [];
@@ -3333,6 +3428,7 @@ const go = (name, id) => {
};
function zurueck() {
sheet = null;
+ fahrtBearbeitenOffen = false;
const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" };
navRichtung = "raus";
route = ziel; render();
@@ -3821,12 +3917,18 @@ function ereignisseVerdrahten() {
}
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" });
+ serviceRufen("audi_dashboard_fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt"));
fahrtOffen = false; render(); return;
}
+ if (e.target.closest("[data-fahrtbearbeiten]")) {
+ fahrtBearbeitenOffen = !fahrtBearbeitenOffen; render(); return;
+ }
+ const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]");
+ if (fahrtAkt) {
+ serviceRufen("audi_dashboard_fahrt_aktualisieren",
+ { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") });
+ fahrtBearbeitenOffen = false; render(); return;
+ }
if (e.target.closest("[data-tankspeichern]")) {
const w = tankFormularWerte("neuTank");
if (w.liter > 0 && w.kosten > 0) {
@@ -3987,7 +4089,14 @@ function ereignisseVerdrahten() {
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; }
+ if (art) {
+ const tr = TRIPS.find((x) => x.trip_id === art.dataset.art);
+ tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg";
+ // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem
+ // naechsten Backend-Push wieder weg - jetzt wandert es mit.
+ serviceRufen("audi_dashboard_fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art });
+ render(); return;
+ }
// Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü-
// DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt).
if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; }
@@ -4209,8 +4318,15 @@ function belegErgebnisVerarbeiten(daten) {
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). */
+ wird (disconnectedCallback stoppt den Timer, connectedCallback stößt ihn
+ über datenLaden() wieder an).
+
+ 2026-08-17: Der Takt darf an keiner Stelle abreißen. Vorher stieg sowohl
+ nachladeAnstossen() (bei fehlendem HASS/callWS) als auch datenLaden() (bei
+ fehlendem HASS) still aus, ohne eine nächste Runde zu planen - danach half
+ nur noch ein zufälliger hass-Push oder ein Klick ins Menü (go() ruft
+ datenLaden() mit auf). Genau das war das gemeldete "Lädt … bis man auf
+ einen Menüpunkt klickt". */
const NACHGELADEN = {};
let nachladeTimer = null;
let nachladeVersuche = 0;
@@ -4219,7 +4335,7 @@ const NACHLADE_LANGSAM_MS = 10000;
const NACHLADE_SCHNELLE_VERSUCHE = 15;
function nachladeAnstossen() {
- if (nachladeTimer || !HASS || !HASS.callWS) return;
+ if (nachladeTimer) return;
nachladeVersuche++;
// Erster Versuch fast sofort statt erst nach vollem Intervall - der
// Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist
@@ -4232,26 +4348,40 @@ function nachladeAnstossen() {
: NACHLADE_LANGSAM_MS;
nachladeTimer = setTimeout(async () => {
nachladeTimer = null;
- try {
- const states = await HASS.callWS({ type: "get_states" });
- const finden = (id) => states.find((s) => s.entity_id === id);
- NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil;
- NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten;
- NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank;
- NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status;
- NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt;
- // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
- // bis zufaellig ein Push kommt - siehe setupBereit().
- NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten;
- } catch (e) {
- // wird beim naechsten Versuch erneut probiert
+ // HASS kann hier noch fehlen: panel_custom erzeugt das Element und setzt
+ // hass nicht immer in dieser Reihenfolge. Frueher brach die Schleife in so
+ // einem Fall oben ganz ab (Bedingung !HASS || !HASS.callWS) und wurde nie
+ // wieder angestossen - genau der dauerhaft haengende "Lädt …"-Bildschirm.
+ // Jetzt wird die Runde nur uebersprungen, der Takt laeuft weiter.
+ if (HASS && HASS.callWS) {
+ try {
+ const states = await HASS.callWS({ type: "get_states" });
+ const finden = (id) => states.find((s) => s.entity_id === id);
+ NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil;
+ NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten;
+ NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank;
+ NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status;
+ NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt;
+ // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer,
+ // bis zufaellig ein Push kommt - siehe setupBereit().
+ NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten;
+ } catch (e) {
+ // wird beim naechsten Versuch erneut probiert
+ }
}
datenLaden(true);
+ // Rueckversicherung: datenLaden() stoesst die naechste Runde selbst an,
+ // aber nur, wenn es nicht vorher aussteigt. Der Takt darf an keiner
+ // Stelle abreissen, solange noch keine Daten da sind.
+ if (!DATEN_GELADEN) nachladeAnstossen();
}, verzoegerung);
}
async function datenLaden(erzwingen) {
- if (!HASS) return;
+ if (!HASS) {
+ if (!DATEN_GELADEN) nachladeAnstossen();
+ 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;
@@ -4355,6 +4485,9 @@ class AudiDashboardPanel extends HTMLElement {
}
disconnectedCallback() {
+ // Nachlade-Takt anhalten, solange das Panel nicht im Dokument haengt -
+ // connectedCallback() stoesst ihn ueber datenLaden() wieder an.
+ if (nachladeTimer) { clearTimeout(nachladeTimer); nachladeTimer = null; }
// Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels
// (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem
// window-Resize-Horcher und laufenden Kachelabrufen am Leben.
diff --git a/homeassistant/www/audi-dashboard-ios.css b/homeassistant/www/audi-dashboard-ios.css
index 5bac293..bf00064 100644
--- a/homeassistant/www/audi-dashboard-ios.css
+++ b/homeassistant/www/audi-dashboard-ios.css
@@ -68,11 +68,21 @@ html,body{height:100%}
/* ---------------- Navigationsleiste ---------------- */
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
-.topbar{align-items:center;gap:4px;padding:56px 20px 4px}
+/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
+ exakt auf der Kante der Kacheln darunter sitzt. */
+.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
.head{display:flex;flex-direction:column}
.eyebrow{display:none}
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
+/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
+ auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
+ ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
+ nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
+ zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
+ (.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
+ bedienbarer Knopf. */
+.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
wie die Kopfzeile selbst. */
@@ -264,12 +274,6 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
- /* Design-Audit 2026-08-13: .back ist auch inaktiv (opacity:0) noch ein
- 44px breites Flex-Element plus Gap - auf Wurzelseiten ohne Zurueck-Pfeil
- schob das die Kopfzeile (.head/.title) sichtbar weiter nach rechts als
- main#view darunter beginnt. Auf Detailseiten (.back.on) bleibt der Platz
- bewusst reserviert, das ist dort ein echter, bedienbarer Knopf. */
- .back:not(.on){width:0;min-width:0;margin:0;overflow:hidden}
.phone:has(.back.on) .head{align-items:flex-start}
.phone:has(.back.on) .title{font-size:26px;text-align:left}
.title{font-size:34px}
@@ -304,8 +308,14 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
.rings{display:none !important}
.duo{grid-template-columns:repeat(3,minmax(0,1fr))}
- .carfix,.bildbox.carfix{height:260px}
- .carfix.klein,.bildbox.carfix.klein{height:220px}
+ /* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
+ Klasse haengt auch am und am Platzhalter-
(siehe
+ bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
+ eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
+ einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
+ abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
+ .bildbox.carfix{height:260px}
+ .bildbox.carfix.klein{height:220px}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
diff --git a/homeassistant/www/audi-dashboard-version.json b/homeassistant/www/audi-dashboard-version.json
index 0d3db61..dfd7c6b 100644
--- a/homeassistant/www/audi-dashboard-version.json
+++ b/homeassistant/www/audi-dashboard-version.json
@@ -1 +1 @@
-{"version": 1786920185}
+{"version": 1787000000}