Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten

Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der
Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner
geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst
beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt.

Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der
alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei
einem Tacho von 61.823).

Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu
None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten.
Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt.

Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort ->
Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt,
Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:28:37 +02:00
parent 78a8017e95
commit 2af5c469d6
27 changed files with 1575 additions and 352 deletions
@@ -754,7 +754,6 @@ let USER_POS_FEHLER = null;
let USER_WATCH = null; // watchPosition-Kennung, solange die Standortseite offen ist
let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung)
let STANDORT_ADRESSE_KEY = null;
let STANDORT_STADT = ""; // reiner Ortsname zum selben Punkt
let STANDORTMENU_OFFEN = false;
let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit"
let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel
@@ -956,34 +955,19 @@ function standortVerfolgungBeenden() {
der Einzelfahrt bisher "unbekannt", obwohl die Position bekannt war
(vom Eigentümer gemeldet, 01.09.2026).
Aufgelöst wird erst beim Öffnen der Fahrt und über denselben Cache wie die
Standortansicht - ein Abruf je Ort, danach nie wieder. Ein Vorratsabruf für
alle Fahrten der Liste wäre genau das, worum Nominatim in seinen
Nutzungsbedingungen bittet, es nicht zu tun. */
Aufgelöst wird seit dem 01.09.2026 im Backend (geokodierung.py) und mit der
Fahrt gespeichert. Der Client fragt nichts mehr ab und hält keinen eigenen
Zwischenspeicher: jedes Gerät zeigt denselben Ort, ein frisch eingerichtetes
Telefon zeigt ihn sofort, und ein geleerter Browserspeicher wirft nichts weg.
"wird ermittelt …" heißt: die Koordinaten stehen, der Ort noch nicht. Das
nächste Screening trägt ihn nach. */
function fahrtOrtText(adresse, lat, lon) {
if (adresse) return esc(adresse);
if (lat == null || lon == null) return "unbekannt";
return "wird ermittelt …";
}
/* Trägt die aufgelösten Orte nach dem Zeichnen ein. Die Fahrt-Kennung wird
mitgeführt: wer während des Abrufs weiterblättert, soll nicht die Adresse
der vorigen Fahrt in der neuen sehen. */
async function fahrtOrteNachtragen(t) {
const id = t.trip_id;
const paare = [
["start", t.start_address, t.start_lat, t.start_lon],
["ziel", t.end_address, t.end_lat, t.end_lon],
];
for (const [feld, adresse, lat, lon] of paare) {
if (adresse || lat == null || lon == null) continue;
const ort = await standortAdresseAufloesen(lat, lon);
const ziel = ROOT.querySelector(`[data-fahrtort="${feld}"]`);
if (!ziel || route.name !== "trip" || route.id !== id) return;
ziel.textContent = ort || "unbekannt";
}
}
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
@@ -1017,8 +1001,7 @@ async function standortAdresseAufloesen(lat, lon) {
const daten = await res.json();
const a = daten.address || {};
const strasse = [a.road, a.house_number].filter(Boolean).join(" ");
STANDORT_STADT = (a.city || a.town || a.village || "").trim();
const ort = [a.postcode, STANDORT_STADT].filter(Boolean).join(" ");
const ort = [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ");
/* Steht das Auto auf einem benannten Platz - Parkplatz, Parkhaus,
Rastplatz -, sagt dessen Name mehr als die naechstgelegene Hausnummer.
Nominatim liefert ihn im Feld name; die Pruefung auf die Art der
@@ -1040,9 +1023,6 @@ async function standortAdresseAufloesen(lat, lon) {
// Nur echte Treffer merken - eine gedrosselte Antwort ist kein "hier gibt es
// keine Adresse" und soll beim naechsten Mal erneut versucht werden.
if (STANDORT_ADRESSE) geoCacheSchreiben(cacheKey, STANDORT_ADRESSE);
// Der reine Ortsname zusaetzlich, fuer die Fahrtenliste - siehe
// stadtAusCache().
if (STANDORT_STADT) geoCacheSchreiben("ort:" + key, STANDORT_STADT);
return STANDORT_ADRESSE;
}
function standortAdresseLaden() {
@@ -1502,7 +1482,7 @@ function teaser() {
const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts");
return `<div class="tile teaser"><span class="label">Zuletzt</span>
${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
<span class="k">Letzte Fahrt<small>${esc(t.start_address || "")} ${esc(t.end_address || "")}</small></span>
<span class="k">Letzte Fahrt<small>${strecke(t)}</small></span>
<span class="v">${streckeText(t.distance_km, "—")}<small>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
@@ -1973,13 +1953,13 @@ function vAudi() {
<dd>${eur(CAR.versicherung.beitrag + CAR.steuer.betrag)} </dd></div>
</dl></button>
<button class="tile tilebtn" data-go="reifen:x">
<span class="label">Reifen</span>
<span class="label">Räder</span>
<svg class="go" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>
<dl class="rows" style="margin-top:10px">
<div class="row"><dt>aktuell montiert:</dt><dd>${r.aktiv}räder</dd></div>
<div class="row"><dt>Laufleistung</dt><dd>${a.km != null ? de(a.km) + " km" : "unbekannt"}</dd></div>
<div class="row" style="border-bottom:0"><dt>Wechseltermin</dt>
<dd>${dedat(new Date(r.wechsel.datum))}${r.wechsel.vereinbart ? "<small>vereinbart</small>" : ""}</dd></div>
<dd>${dedat(new Date(r.wechsel.datum))}${r.wechsel.vereinbart ? "" : ""}</dd></div>
</dl></button>`;
}
@@ -2971,7 +2951,7 @@ function vEinst() {
</div>
<div class="tile"><span class="label">Kraftstoff-Rabatt</span>
<div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:16px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:19.2px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
${CAR.smartdeal.aktiv ? `
<div class="feld" style="border-bottom:0"><label>Gültig bis</label>
@@ -2992,7 +2972,7 @@ function vEinst() {
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
Ausstattung, Räder, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen eine
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
unangetastet.</span>
@@ -3432,10 +3412,8 @@ function vReifen() {
</div>` : ""}
</div>
<div class="tile"><span class="label">Wechseltermin</span>
<div class="feld"><label for="rfDat">Geplant am</label>
<div class="feld" style="border-bottom:0"><label for="rfDat">Geplant am</label>
<input id="rfDat" type="date" value="${r.wechsel.datum}"></div>
<div class="feld" style="border-bottom:0"><label for="rfOk">Termin vereinbart</label>
<label class="switch"><input id="rfOk" type="checkbox" ${r.wechsel.vereinbart ? "checked" : ""}><span class="track"></span></label></div>
<button class="aktion" data-ics="reifen">In den Kalender übernehmen</button>
</div>
${satz("sommer")}
@@ -3725,74 +3703,20 @@ function istNachtZeit(ts) {
}
/* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur
zwischen den Orten, nie am Pfeil. */
/* Ein bereits aufgeloester Ort aus dem Zwischenspeicher - OHNE Netzabruf.
Die Einzelfahrt loest Koordinaten bei Bedarf auf und legt das Ergebnis im
selben Fach ab wie die Standortansicht. Was einmal dort steht, kann die
Liste umsonst mitbenutzen: kein zusaetzlicher Abruf, keine Wartezeit, kein
Flackern beim Zeichnen.
Bewusst NUR aus dem Speicher. Ein Abruf je Zeile waere genau der
Vorratsabruf, um dessen Unterlassung Nominatim in seinen
Nutzungsbedingungen bittet - bei zweihundert Fahrten waeren das
vierhundert Anfragen fuer einen Blick auf die Liste.
Schluessel wie bei der Aufloesung: drei Nachkommastellen (~110 m). */
function ortAusCache(lat, lon) {
if (lat == null || lon == null) return null;
const wert = geoCacheLesen()["rev:" + lat.toFixed(3) + "," + lon.toFixed(3)];
return typeof wert === "string" ? wert : null;
}
/* Der Ortsname allein, fuer die Fahrtenliste.
In der Liste steht eine Fahrt in einer Zeile neben Datum, Art, Strecke und
Verbrauch. Zwei volle Anschriften passen dort nicht - "Schottenau, 85072
Eichstaett -> Am Anger 7a, 85111 Adelschlag" ist laenger als der Rest der
Zeile zusammen. Auf der Einzelfahrt bleibt die vollstaendige Anschrift.
Der Ort wird beim Aufloesen SEPARAT gemerkt (Schluessel "ort:"), nicht
nachtraeglich aus der Anschrift geschnitten. Das Zerlegen der eigenen
Ausgabe waere fast immer richtig und in einem Fall falsch: faellt Nominatim
auf display_name zurueck, steht hinter dem letzten Komma das LAND. Dann
stuende in der Liste "Deutschland -> Deutschland". */
/* Der Ortsname aus einer bereits gespeicherten Anschrift.
Rueckfall fuer alles, was VOR dem Merken des Ortsnamens aufgeloest wurde:
dort liegt nur die volle Anschrift, und ein neuer Netzabruf, nur um den Ort
zu erfahren, waere Verschwendung.
Geschnitten wird ausschliesslich, wenn hinter dem letzten Komma eine
Postleitzahl steht - unser eigenes Format ist "<Strasse>, <PLZ> <Ort>".
Faellt Nominatim auf display_name zurueck, fehlt die PLZ und hinter dem
letzten Komma stuende das LAND; dann lieber die volle Anschrift zeigen als
"Deutschland". */
function stadtAusAnschrift(anschrift) {
if (!anschrift) return null;
const teil = String(anschrift).split(", ").pop().trim();
const treffer = teil.match(/^\d{4,5}\s+(.+)$/);
return treffer ? treffer[1].trim() : null;
}
function stadtAusCache(lat, lon) {
if (lat == null || lon == null) return null;
const wert = geoCacheLesen()["ort:" + lat.toFixed(3) + "," + lon.toFixed(3)];
return typeof wert === "string" && wert ? wert : null;
}
function strecke(t) {
// Ortsname bevorzugt; ohne ihn die volle Anschrift, damit eine vor dieser
// Aenderung aufgeloeste Fahrt nicht ploetzlich wieder ortlos dasteht.
const ortsname = (adresse, lat, lon) => {
if (adresse) return adresse;
const gemerkt = stadtAusCache(lat, lon);
if (gemerkt) return gemerkt;
const voll = ortAusCache(lat, lon);
return stadtAusAnschrift(voll) || voll;
// Der Ortsname kommt mit der Fahrt (start_stadt), aufgeloest im Backend.
//
// Eine VON HAND eingetragene Anschrift sticht ihn: wer "Zuhause" eintraegt,
// will das auch in der Liste lesen und nicht den Ortsnamen dazu. Erkennbar
// an edited_fields - genau dafuer fuehrt das Backend die Liste.
const handisch = new Set(t.edited_fields || []);
const ortsname = (seite) => {
const adresse = t[seite + "_address"];
if (adresse && handisch.has(seite + "_address")) return adresse;
return t[seite + "_stadt"] || adresse || null;
};
const a = ortsname(t.start_address, t.start_lat, t.start_lon);
const b = ortsname(t.end_address, t.end_lat, t.end_lon);
if (!a && !b) return "Fahrt ohne Ortsangabe";
const a = ortsname("start");
const b = ortsname("end"); if (!a && !b) return "Fahrt ohne Ortsangabe";
if (!b) return esc(a);
if (!a) return esc(b);
return `${esc(a)}&nbsp;→ ${esc(b)}`;
@@ -4253,7 +4177,21 @@ function vFill(id) {
/* ---------------------------------------------------------- Steuerung */
const ICONS = {
home: '<path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/>',
/* Auf die volle Rasterbreite gezogen. Live gemessen (getBBox, 01.09./02.09.2026)
fuellte das Symbol nur 18,0 x 10,3 von 24 - 43 % der Hoehe, waehrend die
vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten) liegen. In einer
Reihe liest sich das als "zu klein", und genau so wurde es gemeldet.
Es ist bewusst NUR ein Faktor 1,33 (18 -> 24 Breite, Hoehe 10,3 -> 13,7)
und keine Angleichung auf 72 %: das Symbol ist ein Halbkreis, also
bauartbedingt halb so hoch wie breit. Fuer 72 % Hoehe braeuchte es Faktor
1,68 und waere mit 30 Einheiten breiter als das Raster. Wer es wirklich
gleich hoch will, muss den Bogen neu zeichnen (groesserer Winkel als 180
Grad), nicht skalieren.
stroke-width am <g> gleicht die Skalierung aus: 1,5 / 1,33 = 1,125, damit
die Strichstaerke gerendert wieder 1,5 betraegt wie bei den Nachbarn. */
home: '<g transform="translate(-1.33,-2.67) scale(1.33)" stroke-width="1.125"><path d="M4 17a9 9 0 0 1 18 0"/><path d="M13 17l5.5-5"/><circle cx="13" cy="17" r="1.3"/></g>',
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
@@ -4828,11 +4766,10 @@ function render() {
else if (route.name === "trip") {
head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id);
const t = TRIPS.find((x) => x.trip_id === route.id);
if (t) fahrtOrteNachtragen(t);
}
else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); }
else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); }
else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); }
else if (route.name === "reifen") { head = ["Fahrzeug", "Räder"]; v.innerHTML = vReifen(); }
else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); }
else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); }
else if (route.name === "sicherheit") { head = ["Fahrzeug", "Fahrzeugstatus"]; v.innerHTML = vSicherheit(); }
@@ -5757,7 +5694,7 @@ function ereignisseVerdrahten() {
const rfArchDel = e.target.closest("[data-reifenarchivloeschen]");
if (rfArchDel) {
const id = rfArchDel.dataset.reifenarchivloeschen;
bestaetigen("Archivierten Reifensatz löschen?",
bestaetigen("Archivierten Radsatz löschen?",
"Das lässt sich nicht widerrufen.", "Löschen", () => {
serviceRufen("reifen_archiv_loeschen", { id });
CAR.reifen.archiv = CAR.reifen.archiv.filter((a) => a.id !== id);
@@ -5778,10 +5715,9 @@ function ereignisseVerdrahten() {
} else {
const datum = $("#rfDat").value;
CAR.reifen.wechsel.datum = datum;
CAR.reifen.wechsel.vereinbart = $("#rfOk").checked;
profilSpeichern();
const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer";
ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`);
ics(`${CONFIG.fahrzeugtitel}: Radwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`);
}
return;
}