Fahrten bearbeitbar, Inspektionsprognose, Bootstrap-Takt reisst nicht mehr ab

Sechs gemeldete Punkte plus ein Fund beim Nachlesen des Ladevorgangs:

- HECKKLAPPENSCHLOSS_SENSOR ueberall entfernt (einstellungen.py,
  entitaeten.py-Katalog, _sicherheitscheck()). HECKKLAPPE_SENSOR bleibt.
- Bild-Platzhalter in den Einstellungen war halb abgeschnitten: die bare
  .carfix-Regel der Desktop-Container-Query traf auch den Platzhalter-Div,
  dessen inset:0 von der festen Hoehe uebersteuert wurde. Auf .bildbox
  eingegrenzt.
- Inspektion bekommt eine eigene "voraussichtlich am ..."-Zeile (nur Datum).
  oelwechselPrognose() dafuer auf den gemeinsamen Kern servicePrognose()
  zurueckgefuehrt.
- "Neue Fahrt" bietet jetzt alle Felder, die die Einzelfahrt anzeigt, und die
  Einzelfahrt laesst sich bearbeiten - gemeinsames fahrtFelder()/
  fahrtFormularWerte() nach dem Muster von tankFelder(). Backend: Service
  audi_dashboard_fahrt_aktualisieren, profil.fahrt_bearbeiten() (edited_fields
  schuetzt gegen die Automatik, nicht gegen den Nutzer). Die Art-Pille
  speichert jetzt ebenfalls statt nur lokal umzuschalten.
- Titelzeile steht auf dem Handy buendig zu den Kacheln: .back:not(.on) gibt
  seine Breite und den Gap auf allen Breiten frei, .topbar links 16px.
- Ladebildschirm: nachladeAnstossen() und datenLaden() stiegen bei fehlendem
  HASS aus, ohne eine naechste Runde zu planen - genau die Sackgasse, aus der
  bisher nur ein Menueklick half. Beide planen jetzt weiter, der Timer wird
  beim Abhaengen gestoppt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:40:36 +02:00
parent 91dfd4807a
commit 2d76f10c1c
17 changed files with 670 additions and 161 deletions
+68 -1
View File
@@ -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 `<img>` **and** the
placeholder `<div>`, 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`
@@ -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(<Generator>): 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,
@@ -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,
@@ -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,
@@ -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)
@@ -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]
@@ -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 `<br><span style="color:var(--fg3);font-size:11px">${km}voraussichtlich am ${dedat(p.datum)}</span>`;
}
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
@@ -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 `<div class="row" ${letzte}>
<dt>${t.art}</dt><dd>${x.haupt}${x.neben ? `<small style="text-align:left">${x.neben}</small>` : ""}</dd></div>`;
<dt>${t.art}${t.art === "Inspektion" ? eigen : ""}</dt>
<dd>${x.haupt}${x.neben ? `<small style="text-align:left">${x.neben}</small>` : ""}</dd></div>`;
}
const haupt = fm.ts ? dedat(new Date(fm.ts)) : "";
const neben = fm.km != null ? "in " + de(fm.km) + " km" : "";
// Zusätzlich zur realen Meldung bei Ölwechsel klein die App-eigene
// Prognose (Servicebuch + tatsächliche Fahrleistung seit dem letzten
// Wechsel, siehe oelwechselPrognose()) - bei Inspektion bewusst nicht,
// wie gefordert. Steht links unter dem Kachel-Titel statt rechts bei
// "am"/"in", die beide gleichwertig (gleiche Schriftgröße wie "am")
// untereinander in der rechten Spalte stehen.
const eigen = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
return `<div class="row" ${letzte}>
<dt>${t.art}${eigen ? `<br><span style="color:var(--fg3);font-size:11px">${de(eigen.restKm)} km, voraussichtlich am ${dedat(eigen.datum)}</span>` : ""}</dt>
<dt>${t.art}${eigen}</dt>
<dd>${haupt}${neben ? `<span style="display:block">${neben}</span>` : ""}</dd></div>`;
}).join("")}
</dl></button>
@@ -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 `<div class="row" ${letzte}>
<dt>${t.art}
${t.vereinbart ? `<br><span style="color:var(--red);font-size:11px">Werkstatt am ${dedat(new Date(t.vereinbart))}</span>` : ""}
@@ -1642,8 +1667,10 @@ function vService() {
${fm.km != null || fmDatum ? `
<span class="werte">${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}</span>
${eigenOel ? `<small style="text-align:left">${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}</small>` : ""}
${eigenInsp ? `<small style="text-align:left">voraussichtlich am ${dedat(eigenInsp.datum)}</small>` : ""}
` : `
<span class="werte">${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)}</span>
${eigenInsp ? `<small style="text-align:left">voraussichtlich am ${dedat(eigenInsp.datum)}</small>` : ""}
`}
</dd>
</div>`; }).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 <input type="date"> in Ortszeit. Bewusst nicht über
toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
den Vortag. */
function datumsfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
function zeitfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
}
function fahrtFelder(praefix, t) {
const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
const 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 `
<div class="feld"><label for="${praefix}Datum">Datum</label><input type="date" id="${praefix}Datum" value="${datumsfeld(start)}"></div>
<div class="feld"><label for="${praefix}Start">Startzeit</label><input type="time" id="${praefix}Start" value="${t ? zeitfeld(start) : "08:00"}"></div>
<div class="feld"><label for="${praefix}Dauer">Dauer</label>
<span class="mitEinheit"><input type="number" min="0" id="${praefix}Dauer" value="${dauer}" placeholder="0"><span>min</span></span></div>
<div class="feld"><label for="${praefix}Art">Art</label>
<select id="${praefix}Art">${FAHRT_ARTEN.map((a) => `<option ${a === art ? "selected" : ""}>${a}</option>`).join("")}</select></div>
<div class="feld"><label for="${praefix}Startort">Startort</label><input id="${praefix}Startort" value="${esc(wert(t && t.start_address))}" placeholder="unbekannt"></div>
<div class="feld"><label for="${praefix}Zielort">Zielort</label><input id="${praefix}Zielort" value="${esc(wert(t && t.end_address))}" placeholder="unbekannt"></div>
<div class="feld"><label for="${praefix}OdoStart">Startkilometer</label><input type="number" id="${praefix}OdoStart" value="${wert(t && t.odo_start)}" placeholder="wird ergänzt"></div>
<div class="feld"><label for="${praefix}OdoEnde">Endkilometer</label><input type="number" id="${praefix}OdoEnde" value="${wert(t && t.odo_end)}" placeholder="wird ergänzt"></div>
<div class="feld" style="border-bottom:0"><label for="${praefix}Distanz">Gefahrene Distanz</label>
<span class="mitEinheit"><input type="number" step="0.1" id="${praefix}Distanz" value="${wert(t && t.distance_km)}" placeholder="aus km-Stand"><span>km</span></span></div>`;
}
function fahrtFormularWerte(praefix) {
const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
const 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 ? `<div class="tile"><span class="label">Neue Fahrt</span>
<div class="feld"><label>Datum</label><input type="date" id="neuFahrtDatum" value="${new Date().toISOString().slice(0, 10)}"></div>
<div class="feld"><label>Startzeit</label><input type="time" id="neuFahrtStart" value="08:00"></div>
<div class="feld" style="border-bottom:0"><label>Dauer in Minuten</label><input type="number" id="neuFahrtDauer" placeholder="0"></div>
<button class="aktion" data-fahrtspeichern>Speichern</button></div>` : ""}
<button class="aktion primaer" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
${fahrtFelder("neuFahrt", null)}
<button class="aktion primaer" data-fahrtspeichern>Speichern</button></div>` : ""}
<button class="aktion ${fahrtOffen ? "" : "primaer"}" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
@@ -2491,6 +2570,16 @@ function vTrips() {
function vTrip(id) {
const t = TRIPS.find((x) => x.trip_id === id);
if (!t) return `<div class="tile">Fahrt nicht gefunden</div>`;
if (fahrtBearbeitenOffen) {
return `
<div class="tile"><span class="label">Fahrt bearbeiten</span>
${fahrtFelder("bearbFahrt", t)}
<button class="aktion primaer" data-fahrtaktualisieren="${t.trip_id}">Speichern</button>
<button class="aktion" data-fahrtbearbeiten>Abbrechen</button>
</div>`;
}
return `
${mapBox()}
<div class="tile flat" style="margin:18px 0 14px">
@@ -2509,7 +2598,9 @@ function vTrip(id) {
<div class="row"><dt>Dauer</dt><dd>${Math.round(t.duration_s / 60)} min</dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
<div class="row"><dt>Status</dt><dd>${t.status}<small>Quelle: ${t.km_quelle || ""}</small></dd></div>
</dl></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
</div>
<button class="aktion loeschen" data-tripdel="${t.trip_id}">Fahrt löschen</button>`;
}
@@ -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.
@@ -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 <img> und am Platzhalter-<div> (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}
@@ -1 +1 @@
{"version": 1786920185}
{"version": 1787000000}
+75 -2
View File
@@ -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(<Generator>): 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,
@@ -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,
@@ -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,
@@ -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)
+14 -3
View File
@@ -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]
+192 -59
View File
@@ -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 `<br><span style="color:var(--fg3);font-size:11px">${km}voraussichtlich am ${dedat(p.datum)}</span>`;
}
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
@@ -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 `<div class="row" ${letzte}>
<dt>${t.art}</dt><dd>${x.haupt}${x.neben ? `<small style="text-align:left">${x.neben}</small>` : ""}</dd></div>`;
<dt>${t.art}${t.art === "Inspektion" ? eigen : ""}</dt>
<dd>${x.haupt}${x.neben ? `<small style="text-align:left">${x.neben}</small>` : ""}</dd></div>`;
}
const haupt = fm.ts ? dedat(new Date(fm.ts)) : "";
const neben = fm.km != null ? "in " + de(fm.km) + " km" : "";
// Zusätzlich zur realen Meldung bei Ölwechsel klein die App-eigene
// Prognose (Servicebuch + tatsächliche Fahrleistung seit dem letzten
// Wechsel, siehe oelwechselPrognose()) - bei Inspektion bewusst nicht,
// wie gefordert. Steht links unter dem Kachel-Titel statt rechts bei
// "am"/"in", die beide gleichwertig (gleiche Schriftgröße wie "am")
// untereinander in der rechten Spalte stehen.
const eigen = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
return `<div class="row" ${letzte}>
<dt>${t.art}${eigen ? `<br><span style="color:var(--fg3);font-size:11px">${de(eigen.restKm)} km, voraussichtlich am ${dedat(eigen.datum)}</span>` : ""}</dt>
<dt>${t.art}${eigen}</dt>
<dd>${haupt}${neben ? `<span style="display:block">${neben}</span>` : ""}</dd></div>`;
}).join("")}
</dl></button>
@@ -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 `<div class="row" ${letzte}>
<dt>${t.art}
${t.vereinbart ? `<br><span style="color:var(--red);font-size:11px">Werkstatt am ${dedat(new Date(t.vereinbart))}</span>` : ""}
@@ -1642,8 +1667,10 @@ function vService() {
${fm.km != null || fmDatum ? `
<span class="werte">${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}</span>
${eigenOel ? `<small style="text-align:left">${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}</small>` : ""}
${eigenInsp ? `<small style="text-align:left">voraussichtlich am ${dedat(eigenInsp.datum)}</small>` : ""}
` : `
<span class="werte">${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)}</span>
${eigenInsp ? `<small style="text-align:left">voraussichtlich am ${dedat(eigenInsp.datum)}</small>` : ""}
`}
</dd>
</div>`; }).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 <input type="date"> in Ortszeit. Bewusst nicht über
toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
den Vortag. */
function datumsfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
function zeitfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
}
function fahrtFelder(praefix, t) {
const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
const 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 `
<div class="feld"><label for="${praefix}Datum">Datum</label><input type="date" id="${praefix}Datum" value="${datumsfeld(start)}"></div>
<div class="feld"><label for="${praefix}Start">Startzeit</label><input type="time" id="${praefix}Start" value="${t ? zeitfeld(start) : "08:00"}"></div>
<div class="feld"><label for="${praefix}Dauer">Dauer</label>
<span class="mitEinheit"><input type="number" min="0" id="${praefix}Dauer" value="${dauer}" placeholder="0"><span>min</span></span></div>
<div class="feld"><label for="${praefix}Art">Art</label>
<select id="${praefix}Art">${FAHRT_ARTEN.map((a) => `<option ${a === art ? "selected" : ""}>${a}</option>`).join("")}</select></div>
<div class="feld"><label for="${praefix}Startort">Startort</label><input id="${praefix}Startort" value="${esc(wert(t && t.start_address))}" placeholder="unbekannt"></div>
<div class="feld"><label for="${praefix}Zielort">Zielort</label><input id="${praefix}Zielort" value="${esc(wert(t && t.end_address))}" placeholder="unbekannt"></div>
<div class="feld"><label for="${praefix}OdoStart">Startkilometer</label><input type="number" id="${praefix}OdoStart" value="${wert(t && t.odo_start)}" placeholder="wird ergänzt"></div>
<div class="feld"><label for="${praefix}OdoEnde">Endkilometer</label><input type="number" id="${praefix}OdoEnde" value="${wert(t && t.odo_end)}" placeholder="wird ergänzt"></div>
<div class="feld" style="border-bottom:0"><label for="${praefix}Distanz">Gefahrene Distanz</label>
<span class="mitEinheit"><input type="number" step="0.1" id="${praefix}Distanz" value="${wert(t && t.distance_km)}" placeholder="aus km-Stand"><span>km</span></span></div>`;
}
function fahrtFormularWerte(praefix) {
const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
const 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 ? `<div class="tile"><span class="label">Neue Fahrt</span>
<div class="feld"><label>Datum</label><input type="date" id="neuFahrtDatum" value="${new Date().toISOString().slice(0, 10)}"></div>
<div class="feld"><label>Startzeit</label><input type="time" id="neuFahrtStart" value="08:00"></div>
<div class="feld" style="border-bottom:0"><label>Dauer in Minuten</label><input type="number" id="neuFahrtDauer" placeholder="0"></div>
<button class="aktion" data-fahrtspeichern>Speichern</button></div>` : ""}
<button class="aktion primaer" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
${fahrtFelder("neuFahrt", null)}
<button class="aktion primaer" data-fahrtspeichern>Speichern</button></div>` : ""}
<button class="aktion ${fahrtOffen ? "" : "primaer"}" data-fahrt>${fahrtOffen ? "Abbrechen" : "Fahrt erfassen"}</button>`;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
@@ -2491,6 +2570,16 @@ function vTrips() {
function vTrip(id) {
const t = TRIPS.find((x) => x.trip_id === id);
if (!t) return `<div class="tile">Fahrt nicht gefunden</div>`;
if (fahrtBearbeitenOffen) {
return `
<div class="tile"><span class="label">Fahrt bearbeiten</span>
${fahrtFelder("bearbFahrt", t)}
<button class="aktion primaer" data-fahrtaktualisieren="${t.trip_id}">Speichern</button>
<button class="aktion" data-fahrtbearbeiten>Abbrechen</button>
</div>`;
}
return `
${mapBox()}
<div class="tile flat" style="margin:18px 0 14px">
@@ -2509,7 +2598,9 @@ function vTrip(id) {
<div class="row"><dt>Dauer</dt><dd>${Math.round(t.duration_s / 60)} min</dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
<div class="row"><dt>Status</dt><dd>${t.status}<small>Quelle: ${t.km_quelle || ""}</small></dd></div>
</dl></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
</div>
<button class="aktion loeschen" data-tripdel="${t.trip_id}">Fahrt löschen</button>`;
}
@@ -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.
+19 -9
View File
@@ -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 <img> und am Platzhalter-<div> (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}
@@ -1 +1 @@
{"version": 1786920185}
{"version": 1787000000}