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
+4
View File
@@ -59,6 +59,10 @@ export interface Fahrt {
end_lon?: number | null;
start_address?: string | null;
end_address?: string | null;
/* Nur der Ortsname, vom Backend aufgeloest (geokodierung.py). Die Liste
hat je Fahrt eine Zeile - zwei volle Anschriften passen dort nicht. */
start_stadt?: string | null;
end_stadt?: string | null;
route?: [number, number][] | null;
/** Näherung aus der Literstand-Differenz (TANK_LITER_SENSOR) ÷ Distanz, kein
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
-50
View File
@@ -90,54 +90,6 @@ function cacheSchreiben(suche: string, treffer: Koordinate | null): void {
* vierhundert Anfragen fuer einen Blick auf die Liste.
*
* Schluessel wie bei der Aufloesung: drei Nachkommastellen (~110 m). */
/** 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". */
export function stadtAusAnschrift(anschrift: string | null | undefined): string | null {
if (!anschrift) return null
const teil = String(anschrift).split(", ").pop()?.trim() ?? ""
const treffer = teil.match(/^\d{4,5}\s+(.+)$/)
return treffer && treffer[1] ? treffer[1].trim() : null
}
export function stadtAusCache(
lat: number | null | undefined,
lon: number | null | undefined,
): string | null {
if (lat === null || lat === undefined || lon === null || lon === undefined) return null
const wert = cacheLesen()[`ort:${lat.toFixed(3)},${lon.toFixed(3)}`]
return typeof wert === "string" && wert ? wert : null
}
export function ortAusCache(
lat: number | null | undefined,
lon: number | null | undefined,
): string | null {
if (lat === null || lat === undefined || lon === null || lon === undefined) return null
const wert = cacheLesen()[`rev:${lat.toFixed(3)},${lon.toFixed(3)}`]
return typeof wert === "string" ? wert : null
}
export async function koordinatenAufloesen(lat: number, lon: number): Promise<string | null> {
const schluessel = `rev:${lat.toFixed(3)},${lon.toFixed(3)}`
const cache = cacheLesen()
@@ -173,8 +125,6 @@ export async function koordinatenAufloesen(lat: number, lon: number): Promise<st
// Nur echte Treffer merken: eine gedrosselte Antwort ist kein "hier gibt
// es keine Adresse", die soll beim nächsten Mal erneut versucht werden.
if (adresse) adresseMerken(schluessel, adresse)
// Der reine Ortsname zusätzlich, für die Fahrtenliste — siehe stadtAusCache().
if (stadt) adresseMerken(`ort:${lat.toFixed(3)},${lon.toFixed(3)}`, stadt)
return adresse
} catch {
return null
+1 -1
View File
@@ -92,7 +92,7 @@ export const TITEL: Record<SeitenName, string> = {
service: "Service",
werkstatt: "Autohaus",
sbuch: "Eintrag",
reifen: "Reifen",
reifen: "Räder",
vers: "Versicherung/Steuer",
beitrag: "Beitrag anpassen",
vertrag: "Vertrag",
+1 -1
View File
@@ -535,7 +535,7 @@ export function Einstellungen({
<span className="ads-eyebrow">Fahrzeugprofil</span>
<p className="dm-erklaerung">
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.
+11 -39
View File
@@ -15,7 +15,6 @@ import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui"
import type { Fahrt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { koordinatenAufloesen } from "../daten/geokodierung"
import { datum, dauer, de, streckeTeile, uhrzeit } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
@@ -42,50 +41,23 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
// render()).
const [artVorgriff, setzeArtVorgriff] = useState<"privat" | "arbeitsweg" | null>(null)
/* Ortsangaben zu Start und Ziel.
/* Ortsangaben zu Start und Ziel - beide kommen mit der Fahrt.
Das Backend fuellt start_address/end_address NIE von selbst - sie stehen in
HANDFELDER und kommen nur aus einer von Hand angelegten oder bearbeiteten
Fahrt. Die Koordinaten dagegen traegt das Screening ein. Deshalb stand hier
bisher "unbekannt", obwohl die Position bekannt war (vom Eigentuemer
gemeldet, 01.09.2026).
Aufgeloest wird erst beim Oeffnen der Fahrt und ueber denselben Cache wie
die Standortansicht - ein Abruf je Ort, danach nie wieder. Ein Vorratsabruf
fuer alle Fahrten der Liste waere genau das, worum Nominatim in seinen
Nutzungsbedingungen bittet, es nicht zu tun. */
const [orte, setzeOrte] = useState<{ start: string | null; ziel: string | null }>({
start: null,
ziel: null,
})
const startLat = fahrt?.start_lat
const startLon = fahrt?.start_lon
const endLat = fahrt?.end_lat
const endLon = fahrt?.end_lon
useEffect(() => {
let abgemeldet = false
setzeOrte({ start: null, ziel: null })
const holen = async (lat?: number | null, lon?: number | null) =>
lat == null || lon == null ? null : await koordinatenAufloesen(lat, lon)
void (async () => {
const start = await holen(startLat, startLon)
if (!abgemeldet) setzeOrte((o) => ({ ...o, start }))
const ziel = await holen(endLat, endLon)
if (!abgemeldet) setzeOrte((o) => ({ ...o, ziel }))
})()
return () => {
abgemeldet = true
}
}, [startLat, startLon, endLat, endLon])
Bis zum 01.09.2026 loeste dieser Bildschirm die Koordinaten selbst auf und
legte sie in seinen eigenen Zwischenspeicher. Jetzt macht das Backend es
einmal (geokodierung.py) und speichert das Ergebnis in der Fahrt: jedes
Geraet zeigt denselben Ort, ein frisch eingerichtetes Telefon zeigt ihn
sofort, und ein geleerter Speicher wirft nichts weg.
"wird ermittelt …" heisst: die Koordinaten stehen, der Ort noch nicht -
das naechste Screening traegt ihn nach. */
const ortText = (
adresse: string | null | undefined,
aufgeloest: string | null,
lat?: number | null,
lon?: number | null,
) => adresse ?? aufgeloest ?? (lat == null || lon == null ? "unbekannt" : "wird ermittelt …")
const startOrt = ortText(fahrt?.start_address, orte.start, startLat, startLon)
const zielOrt = ortText(fahrt?.end_address, orte.ziel, endLat, endLon)
) => adresse ?? (lat == null || lon == null ? "unbekannt" : "wird ermittelt …")
const startOrt = ortText(fahrt?.start_address, fahrt?.start_lat, fahrt?.start_lon)
const zielOrt = ortText(fahrt?.end_address, fahrt?.end_lat, fahrt?.end_lon)
// Faellt der Vorgriff mit dem echten Wert zusammen, wird er ueberfluessig.
// Bleibt er stehen, wuerde eine spaetere Aenderung von aussen (Panel,
+18 -22
View File
@@ -9,7 +9,6 @@ import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { ortAusCache, stadtAusAnschrift, stadtAusCache } from "../daten/geokodierung"
import { datum, de, streckeText, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Blattzeile, Leerzustand } from "./bausteine"
@@ -21,31 +20,28 @@ import { Zeilenmenue } from "./Zeilenmenue"
"Fahrt ohne Ortsangabe". Trug bisher in der Liste hier gar nichts bei
(nur auf der Einzelfahrt-Seite sichtbar) - im Panel ist das die
führende Information jeder Fahrtzeile. */
function strecke(fahrt: {
export function strecke(fahrt: {
start_address?: string | null
end_address?: string | null
start_lat?: number | null
start_lon?: number | null
end_lat?: number | null
end_lon?: number | null
start_stadt?: string | null
end_stadt?: string | null
edited_fields?: string[] | null
}): string | null {
// Was die Einzelfahrt schon einmal aufgeloest hat, zeigt die Liste umsonst
// mit - siehe ortAusCache(), kein zusaetzlicher Abruf.
// Ortsname bevorzugt; ohne ihn die volle Anschrift, damit eine vor dieser
// Aenderung aufgeloeste Fahrt nicht ploetzlich wieder ortlos dasteht.
const ortsname = (
adresse: string | null | undefined,
lat: number | null | undefined,
lon: number | null | undefined,
): string | null => {
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 -
// kein Abruf, kein Zwischenspeicher, auf jedem Geraet derselbe Wert.
//
// 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(fahrt.edited_fields ?? [])
const ortsname = (seite: "start" | "end"): string | null => {
const adresse = seite === "start" ? fahrt.start_address : fahrt.end_address
if (adresse && handisch.has(`${seite}_address`)) return adresse
const stadt = seite === "start" ? fahrt.start_stadt : fahrt.end_stadt
return stadt ?? adresse ?? null
}
const a = ortsname(fahrt.start_address, fahrt.start_lat, fahrt.start_lon)
const b = ortsname(fahrt.end_address, fahrt.end_lat, fahrt.end_lon)
const a = ortsname("start")
const b = ortsname("end")
if (!a && !b) return "Fahrt ohne Ortsangabe"
if (!b) return a ?? null
if (!a) return b
+1 -1
View File
@@ -233,7 +233,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
</Tile>
<Tile variant="button" chevron onClick={() => geheZu("reifen")}>
<span className="ads-eyebrow">Reifen</span>
<span className="ads-eyebrow">Räder</span>
<Werteliste
kinder={
<>
+6 -15
View File
@@ -9,7 +9,7 @@
import { useState } from "react"
import { Accordion, ActionButton, Feld, IconButton, Switch, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, IconButton, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext"
@@ -30,17 +30,15 @@ export function Reifen() {
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [wechselVereinbart, setzeWechselVereinbart] = useState<boolean | null>(null)
if (!fahrzeug) return null
const aktiv = fahrzeug.reifen.aktiv
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
const wechsel = fahrzeug.reifen.wechsel as { datum?: string }
// Lokaler Entwurf, solange der Nutzer nichts geändert hat - Panel-
// Verhalten: Datum/Häkchen speichern erst beim Klick auf "In den Kalender
// übernehmen" (data-ics="reifen" liest #rfDat/#rfOk erst dort aus).
// Verhalten: das Datum wird erst beim Klick auf "In den Kalender
// übernehmen" gespeichert (data-ics="reifen" liest #rfDat erst dort aus).
const wechselDatumAnzeige = wechselDatum ?? wechsel.datum ?? ""
const wechselVereinbartAnzeige = wechselVereinbart ?? wechsel.vereinbart ?? false
// Deckungsgleich mit data-nmspeichern im Panel: normales Profilfeld, kein
// eigener Dienst (anders als der Kilometerstand unten).
@@ -56,7 +54,7 @@ export function Reifen() {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige, vereinbart: wechselVereinbartAnzeige } },
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige } },
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
@@ -303,16 +301,9 @@ export function Reifen() {
<Tile>
<span className="ads-eyebrow">Wechseltermin</span>
<Feld label="Geplant am">
<Feld label="Geplant am" last>
<Datumsfeld wert={wechselDatumAnzeige} beiAenderung={setzeWechselDatum} />
</Feld>
<Feld label="Termin vereinbart" last>
<Switch
checked={wechselVereinbartAnzeige}
onChange={(checked) => setzeWechselVereinbart(checked)}
aria-label="Termin vereinbart"
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}>
In den Kalender übernehmen
+10 -7
View File
@@ -10,10 +10,13 @@ import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import { datum, de, deOderStrich, eur, streckeText } from "../format"
import type { SeitenName } from "../navigation"
import { BILDPLAETZE, bildInfo } from "./bilder"
import { Blattzeile, Leerzustand } from "./bausteine"
// Dieselbe Ortsangabe wie in der Fahrtenliste - eine Quelle, damit "Zuletzt"
// und die Liste nicht auseinanderlaufen koennen.
import { strecke } from "./Fahrten"
import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service"
import { StandortVorschau } from "./Standort"
@@ -236,13 +239,13 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
{letzteFahrt && (
<Blattzeile
label="Letzte Fahrt"
labelZusatz={`${letzteFahrt.start_address ?? ""}${letzteFahrt.end_address ?? ""}`}
wert={
letzteFahrt.distance_km != null
? `${de(letzteFahrt.distance_km)} km`
: "offen"
labelZusatz={strecke(letzteFahrt) ?? ""}
wert={streckeText(letzteFahrt.distance_km, "—")}
wertZusatz={
letzteFahrt.verbrauch_l_100km != null
? `${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km`
: "—"
}
wertZusatz={letzteFahrt.status}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
letzte={!letzteTankung}
/>
+12 -6
View File
@@ -1003,8 +1003,12 @@ button.dm-blatt:active {
`1 1 auto` (dem Wert, den das Panel nur für die Zuletzt-Kachel setzt)
brach „Fahrt ohne Ortsangabe" hier auf zwei Zeilen um, obwohl es im Panel
einzeilig steht. */
/* flex:1 statt 0 1: das Label nimmt den freien Platz, damit space-between
keinen Raum ZWISCHEN Label, Werten und Pfeil verteilt - sonst stuende die
Wertespalte in der Mitte und ihre Lage haenge an der Laenge des Ortsnamens
(im Panel gemessen: 549px gegen 520px rechte Kante). */
.dm-blatt__k {
flex: 0 1 auto;
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
@@ -1013,14 +1017,16 @@ button.dm-blatt:active {
line-height: 1.35;
color: var(--fg);
}
/* Ein Flex-Kind darf nur mit min-width:0 unter seine Inhaltsbreite schrumpfen -
ohne das greift text-overflow nie und ein langer Ortsname draengt die Werte. */
.dm-blatt__k > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Feste Mindestbreite, damit Strecke und Verbrauch ueber alle Zeilen hinweg in
einer Spalte stehen. Ohne sie waechst der Kasten mit dem Inhalt - im Panel
gemessen 86px bei "11,4 l/100 km" gegen 37px bei einer Zeile ohne Werte, und
die Zahlen standen entsprechend versetzt. */
.dm-blatt__v {
flex: 0 0 auto;
min-width: 104px;
flex: 0 0 104px;
display: flex;
flex-direction: column;
gap: 3px;
@@ -1714,8 +1720,8 @@ button.dm-listenzeile {
}
.dm-marke img {
/* Auf Zeilenhoehe, siehe .dm-markenlogo weiter unten. */
height: 16px;
/* Siehe .dm-markenlogo weiter unten. */
height: 19.2px;
width: auto;
}
@@ -1779,7 +1785,7 @@ button.dm-listenzeile {
16px, siehe .ads-feld label. Mit 14px wirkte es neben "SmartDeal aktiv"
zu klein (gemeldet vom Eigentuemer, 2026-08-31). */
.dm-markenlogo {
height: 16px;
height: 19.2px;
width: auto;
vertical-align: middle;
}
+15 -1
View File
@@ -44,7 +44,21 @@ export function SymbolUebersicht({ groesse }: SymbolProps) {
<Rahmen
groesse={groesse}
kinder={
<g>
/* 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. */
<g transform="translate(-1.33,-2.67) scale(1.33)" strokeWidth={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" />