From 2d76f10c1cb76d44ddb77b64269eb01bcd7bc585 Mon Sep 17 00:00:00 2001 From: Tobi G Date: Mon, 17 Aug 2026 11:40:36 +0200 Subject: [PATCH] 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 --- AGENTS.md | 69 ++++- .../pyscript/fahrterkennung.py | 77 +++++- .../pyscript/modules/einstellungen.py | 1 - .../pyscript/modules/entitaeten.py | 4 - .../modules/frontend_veroeffentlichung.py | 1 - .../pyscript/modules/profil.py | 17 +- .../www/audi-dashboard-app.js | 251 ++++++++++++++---- .../www/audi-dashboard-ios.css | 28 +- .../www/audi-dashboard-version.json | 2 +- homeassistant/pyscript/fahrterkennung.py | 77 +++++- .../pyscript/modules/einstellungen.py | 1 - homeassistant/pyscript/modules/entitaeten.py | 4 - .../modules/frontend_veroeffentlichung.py | 1 - homeassistant/pyscript/modules/profil.py | 17 +- homeassistant/www/audi-dashboard-app.js | 251 ++++++++++++++---- homeassistant/www/audi-dashboard-ios.css | 28 +- homeassistant/www/audi-dashboard-version.json | 2 +- 17 files changed, 670 insertions(+), 161 deletions(-) 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 `
${t.art} ${t.vereinbart ? `
Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""} @@ -1642,8 +1667,10 @@ function vService() { ${fm.km != null || fmDatum ? ` ${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""} ${eigenOel ? `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}` : ""} + ${eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : ""} ` : ` ${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)} + ${eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : ""} `}
`; }).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 `
Fahrt nicht gefunden
`; + + if (fahrtBearbeitenOffen) { + return ` +
Fahrt bearbeiten + ${fahrtFelder("bearbFahrt", t)} + + +
`; + } + return ` ${mapBox()}
@@ -2509,7 +2598,9 @@ function vTrip(id) {
Dauer
${Math.round(t.duration_s / 60)} min
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
Status
${t.status}Quelle: ${t.km_quelle || "–"}
-
+ + + `; } @@ -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 `
${t.art} ${t.vereinbart ? `
Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""} @@ -1642,8 +1667,10 @@ function vService() { ${fm.km != null || fmDatum ? ` ${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""} ${eigenOel ? `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}` : ""} + ${eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : ""} ` : ` ${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)} + ${eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : ""} `}
`; }).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 `
Fahrt nicht gefunden
`; + + if (fahrtBearbeitenOffen) { + return ` +
Fahrt bearbeiten + ${fahrtFelder("bearbFahrt", t)} + + +
`; + } + return ` ${mapBox()}
@@ -2509,7 +2598,9 @@ function vTrip(id) {
Dauer
${Math.round(t.duration_s / 60)} min
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
Status
${t.status}Quelle: ${t.km_quelle || "–"}
-
+ + + `; } @@ -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}