Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt (2026.9.4.1)

Das Fahrtfenster stand seit dem 01.09. in der Gerätezeit, der Verlauf war nach
Ankunftszeit sortiert. Zwei Uhren, und das Gerät puffert: was verspätet ankam,
fiel aus einem Fenster heraus, in dem es der Sache nach lag. Am 03.09. hat das
eine Fahrt gekostet - 26 von 48 GPS-Punkten und 3,0 statt 7 km.

verlauf_lesen() stempelt jeden Punkt jetzt mit der Gerätezeit seines
Datensatzes. Eine Stelle, an der alle Verbraucher vorbeikommen. Dazu zwei
Riegel aus derselben Untersuchung: ein Rückschritt von 43 Metern ist keine
Zählerrücksetzung mehr (aus 87 Metern wurden vorher 13,7 km), und das
Fahrtende wird auf das letzte Lebenszeichen des Geräts geklemmt - der Nachlauf
ist eine Rechnung, kein Messwert.

Am echten Recorder nachgerechnet: Tacho 3,0 -> 7,0 km, GNSS 2,952 -> 6,877 km,
Route 26 -> 47 Punkte, Ende 23:21:57 -> 23:17:48. Beim ersten Lauf haben zwei
Fahrten eine Strecke bekommen, die vorher gar keine hatten.

Nicht das Gerät war schuld: es hat lückenlos aufgezeichnet. Die zehn Minuten
Funkstille waren eine offene, aber tote TCP-Sitzung (flespi-Log: 703 s, 16
Nachrichten) - kein Funkloch. Begründung und die Empfehlung fürs Gerät stehen
in AGENTS.md, Abschnitt CB.

Dazu die neun Paritätsbefunde, alle Richtung Panel gelöst - darunter ein
unmaskierter Punkt in beiden Codebasen, der aus "vor 3 Tage" ein "vor 3 Tag"
machte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 02:18:12 +02:00
parent 4a6c48e81c
commit 7f386d19ea
21 changed files with 826 additions and 120 deletions
+64 -62
View File
@@ -396,6 +396,66 @@ export function Einstellungen({
</ActionButton>
</Tile>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
@@ -429,11 +489,12 @@ export function Einstellungen({
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen} ist unterwegs zum Gerät`
? `${w.offen}, unterwegs`
: w.abweichung
? `wir rechnen mit ${w.unser}`
? `(wir: ${w.unser})`
: undefined
}
/>
@@ -448,7 +509,7 @@ export function Einstellungen({
{dongle.stand?.gelesen_am
// Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
// stünde dort "vor 1 Std..".
? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/.$/, "")}. `
? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/\.$/, "")}. `
: "Noch nicht gelesen. "}
Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
das nächste Mal meldet, steht die Änderung als unterwegs" daneben.
@@ -744,65 +805,6 @@ export function Einstellungen({
</Tile>
)}
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen(`Token für ${z.titel} löschen?`)) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es in Home Assistant unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Version</span>
+10 -8
View File
@@ -115,6 +115,8 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
: {})}
{...(fahrt.route && fahrt.route.length >= 2 ? { route: fahrt.route } : {})}
startText={fahrt.start_address ?? fahrt.start_stadt ?? ""}
zielText={fahrt.end_address ?? fahrt.end_stadt ?? ""}
/>
) : (
<Leerzustand
@@ -166,14 +168,6 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "noch offen"}
/>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
/>
<Wertzeile
label="Ø Geschwindigkeit"
wert={
@@ -188,6 +182,14 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh)} km/h` : "liegt nicht vor"
}
/>
<Wertzeile
label="Verbrauch"
wert={
fahrt.verbrauch_l_100km != null
? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km`
: "liegt nicht vor"
}
/>
</>
}
/>
+86 -8
View File
@@ -28,6 +28,24 @@ const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
const ROT = "#F50537"
/* Nadel der Tankstelle: die CI-Symbole poi-s und seine Silhouette, verbatim
aus dem Panel (tankstellenMarkerSVG). Bewusst die schlichte Nadel und nicht
poi-car, damit Fahrzeug und Tankstelle unterscheidbar bleiben. Die
Silhouette liegt darunter, weil die reine Kontur auf Kartenkacheln fast
unsichtbar ist - der Befund vom 16.08.2026, gemessen an der Deckung. */
const CI_POI_SIL = '<path fill="#182634" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5"/>'
const CI_POI = '<path fill="currentColor" d="M4 8.5C4 4.32 7.32 1 11.5 1S19 4.32 19 8.5c0 1.09-.48 2.53-1.14 4.03a49 49 0 0 1-2.43 4.68A92 92 0 0 1 12 22.67l-.06.08-.01.03-.42-.28-.41.28-.02-.03-.06-.08A66 66 0 0 1 10 21.13a92 92 0 0 1-2.42-3.92 49 49 0 0 1-2.43-4.68C4.48 11.03 4 9.6 4 8.5m7.5 14-.41.28.41.6.41-.6zm0-.89a92 92 0 0 0 3.07-4.9c.87-1.5 1.73-3.1 2.38-4.58A10.4 10.4 0 0 0 18 8.5C18 4.88 15.12 2 11.5 2A6.46 6.46 0 0 0 5 8.5c0 .86.4 2.14 1.05 3.63a48 48 0 0 0 2.38 4.58 91 91 0 0 0 3.07 4.9M8 8.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0M11.5 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"/>'
const CI_GPS = 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z'
function nadelSvg(px = 32): string {
return (
`<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true">` +
CI_POI_SIL +
CI_POI +
"</svg>"
)
}
/* Fahrtrichtung auf der Streckenlinie. Deckungsgleich mit spurPfeile() im
Panel - dort steht die ausfuehrliche Begruendung.
@@ -105,13 +123,28 @@ export function Karte({
start,
ziel,
route,
startText,
zielText,
nadel,
}: {
start: Punkt
ziel?: Punkt
/** Echte Streckenlinie aus dem GPS-Verlauf (Backend: screening.py/historienimport.py). */
route?: [number, number][]
/** Anschrift zum Startpunkt - steht im Popup, wie im Panel (initMap). */
startText?: string | null
/** Anschrift zum Zielpunkt. */
zielText?: string | null
/**
* Ein einzelner Ort statt Start und Ziel: die CI-Nadel mit Popup und einem
* Knopf, der wieder darauf zentriert. So zeichnet das Panel den Einzelbeleg
* (tankstelleAufKarteZeigen/mapBoxMitZentrieren); die App zeigte hier bis
* zum 04.09.2026 nur den schwarzen Startpunkt ohne Namen.
*/
nadel?: { name?: string | null; adresse?: string | null }
}) {
const behaelter = useRef<HTMLDivElement | null>(null)
const karteRef = useRef<import("leaflet").Map | null>(null)
useEffect(() => {
const element = behaelter.current
@@ -127,17 +160,32 @@ export function Karte({
karte = L.map(element, { attributionControl: true, scrollWheelZoom: false })
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
karteRef.current = karte
const punkte: [number, number][] = [[start.lat, start.lon]]
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
.bindTooltip("Start")
.addTo(karte)
if (nadel) {
const symbol = L.divIcon({
className: "dm-nadel",
html: nadelSvg(32),
iconSize: [32, 32],
iconAnchor: [15, 31],
})
L.marker([start.lat, start.lon], { icon: symbol })
.bindPopup([nadel.name, nadel.adresse].filter(Boolean).join("<br>"))
.addTo(karte)
} else {
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
// Popup statt Tooltip, ebenfalls wie im Panel: auf dem Telefon ist ein
// Tooltip praktisch unsichtbar, und die Anschrift ist die Auskunft.
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
.bindPopup("Start · " + (startText ?? ""))
.addTo(karte)
}
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: ROT, weight: 2, fillColor: ROT, fillOpacity: 1 })
.bindTooltip("Ziel")
.bindPopup("Ziel · " + (zielText ?? ""))
.addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
@@ -162,9 +210,39 @@ export function Karte({
return () => {
abgebrochen = true
karteRef.current = null
karte?.remove()
}
}, [start, ziel, route])
}, [start, ziel, route, startText, zielText, nadel])
return <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
const karte = (
<div
ref={behaelter}
className="dm-karte"
role="img"
aria-label={nadel ? "Karte der Tankstelle" : "Karte der Fahrt"}
/>
)
if (!nadel) return karte
return (
<div className="dm-kartenbox">
{karte}
<div className="dm-kartensteuerung dm-kartensteuerung--klein">
<button
type="button"
aria-label="Auf die Tankstelle zentrieren"
onClick={() =>
karteRef.current?.flyTo(
[start.lat, start.lon],
Math.max(karteRef.current.getZoom(), 14),
)
}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path fill="currentColor" d={CI_GPS} />
</svg>
</button>
</div>
</div>
)
}
+2 -2
View File
@@ -69,7 +69,7 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
return (
<>
<Tile className="dm-kartenkachel">
<TankstellenKarte adresse={tank.station_address} />
<TankstellenKarte name={tank.station_name} adresse={tank.station_address} />
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
@@ -367,7 +367,7 @@ function BelegKnopf({
const hochladen = async (datei: File) => {
setzeMeldung(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeMeldung({ art: "fehler", text: "Bitte den Beleg als PDF auswählen." })
setzeMeldung({ art: "fehler", text: "Nur PDF-Belege werden unterstützt" })
return
}
setzeLaeuft(true)
+1 -1
View File
@@ -223,7 +223,7 @@ function TankFormular({
const belegLesen = async (datei: File) => {
setzeFehler(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeFehler("Bitte den Beleg als PDF auswählen.")
setzeFehler("Nur PDF-Belege werden unterstützt")
return
}
setzeBelegLaeuft(true)
+11 -2
View File
@@ -15,7 +15,13 @@ import type { Koordinate } from "../daten/geokodierung"
import { Leerzustand } from "./bausteine"
import { Karte } from "./Karte"
export function TankstellenKarte({ adresse }: { adresse: string | null | undefined }) {
export function TankstellenKarte({
name,
adresse,
}: {
name?: string | null | undefined
adresse: string | null | undefined
}) {
const [position, setzePosition] = useState<Koordinate | null>(null)
const [geprueft, setzeGeprueft] = useState(false)
@@ -32,7 +38,10 @@ export function TankstellenKarte({ adresse }: { adresse: string | null | undefin
}
}, [adresse])
if (position) return <Karte start={position} />
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
if (position)
return <Karte start={position} nadel={{ name: name ?? null, adresse: adresse ?? null }} />
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
+11 -1
View File
@@ -256,6 +256,7 @@ export function Wertzeile({
wert,
zusatz,
hinweis,
warnung,
}: {
label: ReactNode
wert: ReactNode
@@ -264,11 +265,20 @@ export function Wertzeile({
Angabe - fuer Zeilen, deren rechte Seite nur beschreibt, was hinter der
Kachel liegt, statt selbst ein Messwert zu sein. */
hinweis?: boolean
/** Hebt den Wert als abweichend hervor (var(--warn)), wie das Panel es bei
einem Dongle-Wert tut, der nicht zu unserer Einstellung passt. */
warnung?: boolean
}) {
return (
<div className="dm-wertzeile">
<dt>{label}</dt>
<dd className={hinweis ? "dm-wertzeile__hinweis" : undefined}>
<dd
className={
[hinweis ? "dm-wertzeile__hinweis" : "", warnung ? "dm-wertzeile__warnung" : ""]
.filter(Boolean)
.join(" ") || undefined
}
>
{wert}
{zusatz && <small>{zusatz}</small>}
</dd>
+29
View File
@@ -1696,6 +1696,30 @@ button.dm-listenzeile {
transform: scale(0.94);
}
/* Karte mit eigener Bedienung darauf (Einzelbeleg). Der Kasten traegt die
Positionierung, damit der Knopf sich an DIESER Karte ausrichtet und nicht am
naechsten Vorfahren weiter oben - dieselbe Ueberlegung wie .mapbox im Panel. */
.dm-kartenbox {
position: relative;
border-radius: var(--r-tile);
overflow: hidden;
}
/* Kleine Variante: die Belegkarte ist nur gut 200px hoch, ein 48er-Knopf mit
16px Abstand verdeckte davon ein Viertel. */
.dm-kartensteuerung--klein {
top: 10px;
right: 10px;
}
.dm-kartensteuerung--klein button {
width: 36px;
height: 36px;
}
/* Die CI-Nadel traegt ihre Farben selbst; der Schatten hebt sie von den
Kartenkacheln ab, wie .fahrzeug-pin im Panel. */
.dm-nadel {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
}
.dm-standortmenu {
position: absolute;
z-index: 410;
@@ -1995,3 +2019,8 @@ button.dm-listenzeile {
.dm-token-gesetzt { color: var(--ok); }
.dm-token-offen { color: var(--fg3); }
/* Abweichender Wert - im Panel steht er in var(--warn), hier ebenso. */
.dm-wertzeile__warnung {
color: var(--warn);
}