Fahrten, Statistik, Trip-Timer (2026.8.31.3)

Der 15-Minuten-Timer war doppelt: der FMM003 wartet laut Konfiguration
(Trip \ Odometer, Ignition OFF Timeout) selbst 900 s, die App noch einmal
15 Minuten. Zusammen eine halbe Stunde. Die Pausenregel ist damit entfallen -
in der Live-Erkennung, im Historienimport und in beiden Oberflaechen. Der
Schwellwert "ab wann ist es ein Parkplatz" hing an derselben Einstellung, hat
damit aber nichts zu tun und steht jetzt als eigene Zahl.

Hoechst- und Durchschnittsgeschwindigkeit je Fahrt. Der Durchschnitt folgt aus
Strecke und Dauer, vmax aus der neuen Rolle GESCHWINDIGKEIT_SENSOR - laut
Konfiguration liest das Geraet die Geschwindigkeit vom OBD/CAN. Es ist der
groesste gemeldete Wert, nicht die tatsaechliche Spitze: alle zehn Sekunden
ein Datensatz. So ist es auch beschriftet.

Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch. Gemessen wird
die Strecke ZWISCHEN erstem und letztem Tankstop; verbraucht wurde darauf,
was ab dem ZWEITEN hineinkam - der erste fuellte die Strecke davor. Bei 13
Tankvorgaengen rund 8 Prozent, im Testfall 16,7 statt 8,3 l/100 km. Der Test
hatte die falsche Rechnung festgeschrieben und ist mitkorrigiert. Eine
negative Strecke (falscher Kilometerstand an einem Tankvorgang) gilt jetzt
als unplausibel statt als Null ohne Erklaerung.

Verbrauch je Fahrt bekommt Grenzen: am Sensor nachgemessen loest
can_fuel_volume 0,1 l auf, ein Schritt entspricht also 10/Strecke l/100 km
Fehler. Unter 3 km sagt die Differenz nichts mehr, ueber 60 l/100 km ist es
kein Verbrauch.

Statistik: zusaetzlich "Absolut", und die Einheit steht jetzt in eckigen
Klammern an der Rubrik statt vier Mal am Zeitraum. Fuenf Spalten passen auf
einem Telefon nicht nebeneinander, das Raster bricht um.

Radfoto in Reifen war ein reines <Bild> - Ersetzen und Loeschen von dort aus
gar nicht erreichbar. Jetzt BildMitMenue wie im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:03:28 +02:00
parent 17dc42e645
commit 4af38d7e65
23 changed files with 379 additions and 129 deletions
+1
View File
@@ -49,6 +49,7 @@ export interface Fahrt {
edited_fields?: string[];
/* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */
avg_speed_kmh?: number | null;
vmax_kmh?: number | null;
start_lat?: number | null;
start_lon?: number | null;
end_lat?: number | null;
@@ -22,8 +22,7 @@ function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
},
einstellungen: {
uebersichtsbild: "seitenansicht.webp",
fahrten_pausenzeit_min: 15,
smartdeal: { aktiv: true, laeuft_ab: null },
smartdeal: { aktiv: true, laeuft_ab: null },
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
},
reifen: {
@@ -42,13 +41,11 @@ describe("profilZuEinstellungen", () => {
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
expect(e.kennzeichen).toBe("M AB 1234")
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
})
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
const e = profilZuEinstellungen({})
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
expect(e.backupIntervall).toBe("aus")
})
})
-3
View File
@@ -19,7 +19,6 @@ export interface Einstellungen {
tankvolumen: number
nachtVon: string
nachtBis: string
pauseMin: number
markenname: string
backupIntervall: string
letztesBackup: string | null
@@ -138,7 +137,6 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"),
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
markenname: text(einst, "kraftstoffanbieter", "Shell"),
backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null,
@@ -237,7 +235,6 @@ export function zusammenfuehren(
f["hauptuntersuchung_faellig"] = fahrzeug.hu
e["uebersichtsbild"] = einstellungen.startbild
e["fahrten_pausenzeit_min"] = einstellungen.pauseMin
e["backup_intervall"] = einstellungen.backupIntervall
e["nacht_von"] = einstellungen.nachtVon
e["nacht_bis"] = einstellungen.nachtBis
+33 -4
View File
@@ -118,19 +118,48 @@ describe("fahrtenSeit und tankSeit", () => {
})
describe("langzeitverbrauch", () => {
it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => {
it("rechnet von Tank zu Tank, ohne den ersten Tankvorgang", () => {
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(600)
expect(ergebnis.liter).toBe(100)
// 100 l auf 600 km sind 16,7 l/100 km — plausibel.
expect(ergebnis.wert).toBeCloseTo(16.67, 1)
// Nur die 50 l aus Tankvorgang b: gemessen wird die Strecke ZWISCHEN a
// und b, und was dort verbraucht wurde, ist genau das, was bei b wieder
// hineingefüllt wurde. Die 50 l aus a füllten die Strecke davor - sie
// mitzuzählen war bis zum 2026-08-31 der Fehler und wies den Verbrauch
// um einen ganzen Tankinhalt zu hoch aus (hier: 16,7 statt 8,3).
expect(ergebnis.liter).toBe(50)
expect(ergebnis.wert).toBeCloseTo(8.33, 1)
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
})
it("zählt bei drei Tankvorgängen die letzten beiden", () => {
const tanks = [
tankung({ tank_id: "a", ts: "2026-05-01T10:00:00", liters: 40, odometer_km: 10000 }),
tankung({ tank_id: "b", ts: "2026-06-01T10:00:00", liters: 45, odometer_km: 10500 }),
tankung({ tank_id: "c", ts: "2026-07-01T10:00:00", liters: 55, odometer_km: 11000 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(1000)
expect(ergebnis.liter).toBe(100)
expect(ergebnis.wert).toBeCloseTo(10, 1)
})
it("lässt eine rückwärts laufende Kilometerreihe nicht als Wert durchgehen", () => {
// Ein falscher Kilometerstand an einem Tankvorgang ist in diesem Projekt
// schon vorgekommen (AGENTS.md, Abschnitt AF). Eine negative Strecke ist
// kein Messwert.
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40600 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40000 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(0)
expect(ergebnis.wert).toBeNull()
})
it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => {
const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })]
const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })]
+26 -4
View File
@@ -9,19 +9,23 @@
import type { Fahrt, Tankvorgang } from "../api"
import { summe } from "../format"
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
jahr: "Jahr",
monat: "Monat",
woche: "Woche",
tag: "Tag",
absolut: "Absolut",
}
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
// Vor jeder denkbaren Aufzeichnung - damit fällt nichts heraus.
if (art === "absolut") return new Date(0)
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
const d = new Date(jetzt)
@@ -88,33 +92,51 @@ export function langzeitverbrauch(
const letzter = tanks[tanks.length - 1]!
const bis = new Date(letzter.ts)
const liter = summe(tanks, (t) => t.liters ?? 0)
// Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch
// Strecke mit, für die keine Fahrt aufgezeichnet wurde.
const mitOdo = tanks.filter((t) => t.odometer_km != null)
let km: number
let liter: number
let basis: Langzeitverbrauch["basis"]
if (mitOdo.length >= 2) {
km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0)
basis = "Kilometerstand an den Tankvorgängen"
// Von-Tank-zu-Tank: gemessen wird die Strecke ZWISCHEN erstem und
// letztem Tankstop, verbraucht wurde auf ihr alles, was ab dem zweiten
// Tankstop hineingefüllt wurde. Der erste Tankvorgang füllte die Strecke
// DAVOR — ihn mitzuzählen hat den Verbrauch um einen ganzen Tankinhalt
// zu hoch ausgewiesen (bei 13 Tankvorgängen rund 8 %). Korrigiert am
// 2026-08-31.
liter = summe(mitOdo.slice(1), (t) => t.liters ?? 0)
} else {
km = summe(
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
(f) => f.distance_km ?? 0,
)
basis = "erfasste Fahrten"
// Ohne Kilometerstände ist die Bezugsstrecke alles Erfasste bis zum
// letzten Tankstop — dann zählt auch der erste Tankvorgang dazu.
liter = summe(tanks, (t) => t.liters ?? 0)
}
// Eine negative Strecke ist kein Messwert, sondern ein falscher
// Kilometerstand an einem Tankvorgang. Sie darf nicht als Zahl durchgehen.
if (km < 0) km = 0
const roh = km > 0 ? (liter / km) * 100 : null
const gueltig = roh !== null && roh >= 3 && roh <= 30
// Auch eine Strecke von null zählt als unplausibel, wenn sie aus
// Kilometerständen kommt: dann laufen die Werte an den Tankvorgängen
// rückwärts. Ohne diesen Zweig stünde dort nur „0 km" und „–", ohne jeden
// Hinweis, woran es liegt.
const streckeFehlt = basis === "Kilometerstand an den Tankvorgängen" && km <= 0
return {
wert: gueltig ? roh : null,
km,
liter,
bis,
basis,
unplausibel: roh !== null && !gueltig,
unplausibel: (roh !== null && !gueltig) || streckeFehlt,
}
}
+4 -15
View File
@@ -500,21 +500,10 @@ export function Einstellungen({
{/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie
in den Einstellungen nicht, die Nachtgrenze steht dort nur als
Erlaeuterung auf der Statistik-Seite. */}
<Tile>
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
<Feld label="Pause bis" unit="Minuten" last>
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.pauseMin}
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
/>
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Steigst du kürzer als diese Zeit aus etwa zum Tanken , bleibt es eine
zusammenhängende Fahrt statt zweier
</span>
</Tile>
{/* Die Kachel „Fahrten zusammenfassen" ist am 2026-08-31 entfallen:
der FMM003 wendet die Pausenregel selbst an (Ignition OFF Timeout
= 900 s), eine zweite Wartezeit in der App haette daraus eine
halbe Stunde gemacht. */}
<HistorienImport
offen={importOffen}
+16
View File
@@ -153,6 +153,22 @@ export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (n
}
zusatz="Näherung aus Tankfüllstand"
/>
<Wertzeile
label="Ø Geschwindigkeit"
wert={
fahrt.avg_speed_kmh != null
? `${de(fahrt.avg_speed_kmh, 1)} km/h`
: "liegt nicht vor"
}
zusatz="Strecke ÷ Dauer, Standzeiten eingerechnet"
/>
<Wertzeile
label="Höchstgeschwindigkeit"
wert={
fahrt.vmax_kmh != null ? `${de(fahrt.vmax_kmh, 1)} km/h` : "liegt nicht vor"
}
zusatz="größter gemeldeter Wert, alle 10 s ein Datensatz"
/>
</>
}
/>
+5 -3
View File
@@ -16,9 +16,8 @@ import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
@@ -141,8 +140,11 @@ export function Reifen() {
<span className="ads-fig__unit">km</span>
</div>
</div>
{/* Antippbar wie im Panel (data-bildklick am selben Foto): ein
reines <Bild> zeigte es nur an, Ersetzen und Loeschen waren
von hier aus gar nicht erreichbar. */}
<div className="dm-radfoto">
<Bild
<BildMitMenue
datei={satzSchluessel === "winter" ? "rad-winter.webp" : "rad-sommer.webp"}
beschreibung={radLabel}
name={radLabel}
+8 -3
View File
@@ -106,10 +106,15 @@ function dauerSeitText(ts: number | null): string {
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
standortZustand() im Panel. */
/* Ab wann aus „steht gerade" ein „Parkplatz" wird. Hing bis 2026-08-31 an
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
const PARKPLATZ_AB_MIN = 15
function standortZustand(
fahrzeug: Fahrzeug,
fahrten: readonly { ts_end: string }[],
pauseMin: number,
): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } {
let neuesteTs: number | null = null
for (const t of fahrten) {
@@ -122,7 +127,7 @@ function standortZustand(
faehrt: false,
unbekannt: fahrzeug.zuendung == null,
seitTs: neuesteTs,
geparkt: minuten != null && minuten >= pauseMin,
geparkt: minuten != null && minuten >= PARKPLATZ_AB_MIN,
}
}
@@ -437,7 +442,7 @@ export function Standort() {
: (positionsFehler ?? "Abstand wird ermittelt …")
const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : ""
const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten)
const zustand = standortZustand(fahrzeug, fahrten, einstellungen.pauseMin)
const zustand = standortZustand(fahrzeug, fahrten)
const kartenlink = fahrzeugPos
? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}`
: null
+15 -17
View File
@@ -59,7 +59,7 @@ function StatKachel({
)
}
/** `quad()` im Panel: Zwischenüberschrift plus vier Werte nebeneinander. */
/** `quad()` im Panel: Zwischenüberschrift plus die Werte je Zeitraum. */
function Vierergruppe({ titel, werte }: { titel: string; werte: [string, string][] }) {
return (
<>
@@ -100,13 +100,12 @@ export function Statistik() {
einstellungen.nachtBis,
)
// Die Einheit steht im Panel in der Beschriftung der Vierergruppe
// ("Jahr h", "Monat €"), nicht am Wert.
const jeZeitraum = (
auswahl: (z: Zeitraum) => string,
einheit = "",
): [string, string][] =>
ZEITRAEUME.map((z) => [auswahl(z), `${ZEITRAUM_NAME[z]}${einheit}`])
// Die Einheit steht seit dem 2026-08-31 in eckigen Klammern an der
// Rubrik ("Fahrzeit [h]") statt am Zeitraum ("Jahr h") - dort stand sie
// vier Mal für dieselbe Sache und fehlte in der Überschrift, die man
// zuerst liest.
const jeZeitraum = (auswahl: (z: Zeitraum) => string): [string, string][] =>
ZEITRAEUME.map((z) => [auswahl(z), ZEITRAUM_NAME[z]])
return (
<>
@@ -117,14 +116,13 @@ export function Statistik() {
neben={`${de(a.stundenJahr)} h`}
>
<Vierergruppe
titel="Gefahrene Distanz"
titel="Gefahrene Distanz [km]"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].km))}
/>
<Vierergruppe
titel="Fahrzeit"
werte={jeZeitraum(
(z) => de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
" h",
titel="Fahrzeit [h]"
werte={jeZeitraum((z) =>
de(a.jeZeitraum[z].stunden, z === "woche" || z === "tag" ? 1 : 0),
)}
/>
</StatKachel>
@@ -136,12 +134,12 @@ export function Statistik() {
neben={`${de(a.kostenJahr)}`}
>
<Vierergruppe
titel="Getankte Liter"
titel="Getankte Liter [l]"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].liter))}
/>
<Vierergruppe
titel="Kraftstoffkosten"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten), " €")}
titel="Kraftstoffkosten [€]"
werte={jeZeitraum((z) => de(a.jeZeitraum[z].kosten))}
/>
</StatKachel>
@@ -174,7 +172,7 @@ export function Statistik() {
</span>
)}
<Vierergruppe
titel="Näherung je Fahrt, je Zeitraum"
titel="Näherung je Fahrt, je Zeitraum [l/100 km]"
werte={jeZeitraum((z) => deOderStrich(a.jeZeitraum[z].verbrauch, 1))}
/>
<span className="ads-eyebrow" style={{ marginTop: "16px" }}>
+6 -1
View File
@@ -680,9 +680,14 @@ button.dm-serviceblock:active {
}
/* `.quad` im Panel: vier Werte nebeneinander, Zahl im Wide-Schnitt. */
/* Fuenf Spalten (Jahr/Monat/Woche/Tag/Absolut) passen auf einem Telefon nicht
nebeneinander: bei rund 300px Innenbreite blieben je 51px, und "209.177"
braucht bei 16px Audi Type Wide etwa 60. auto-fit bricht deshalb um - auf dem
Telefon drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer
Reihe. Deckungsgleich mit .quad im Panel. */
.dm-quad {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
gap: var(--sp-3);
margin-top: var(--sp-3);
}
-1
View File
@@ -42,7 +42,6 @@ export const beispielProfil: Profil = {
einstellungen: {
nacht_von: "22:00",
nacht_bis: "06:00",
fahrten_pausenzeit_min: 15,
uebersichtsbild: "seitenansicht.webp",
kraftstoffanbieter: "Shell",
smartdeal: { aktiv: true, laeuft_ab: null },