Phase 7a: Uebersicht, Fahrzeugstatus und Mein Audi

Erste drei Screens auf der fertigen Datenschicht. Uebersicht mit Fahrzeugbild,
Sicherungsstatus, Reichweite und Tankfuellstand, Kilometerstand samt
Service-Faelligkeit und Vorschau auf letzte Fahrt und Tankung. Fahrzeugstatus
zeigt die 16 Einzelpruefungen, wobei ein unbekannter Sensor als hohler Ring
erscheint statt als gruen geratener Punkt - dieselbe Vorsicht, die schon das
Backend walten laesst. Mein Audi buendelt Fotogalerie und Wege zu den
Unterseiten.

Dazu die geteilte Rechenlogik der Statistik portiert (Zeitraeume,
Langzeitverbrauch mit Plausibilitaetsfenster, Gruppierung nach Jahr und
Monat) und Formatierung im deutschen Format.

Bild-Komponente kapselt eine Reibung zwischen App und Bibliothek: die App
laeuft mit exactOptionalPropertyTypes, ein undefined an einem optionalen Prop
waere sonst an jeder Aufrufstelle einzeln zu behandeln.
This commit is contained in:
Paul Nothaft
2026-08-11 11:07:03 +02:00
parent 0103508cea
commit 4dbf3cb173
12 changed files with 1003 additions and 4 deletions
+242
View File
@@ -0,0 +1,242 @@
/**
* Auswertung von Fahrten und Tankvorgängen.
*
* Aus dem alten Panel portiert (`periodStart`, `fahrtenSeit`, `tankSeit`,
* `istNachtZeit`, `langzeitverbrauch`). Die Rechenregeln sind bewusst
* unverändert — sie sind über mehrere Runden entstanden und begründet.
*/
import type { Fahrt, Tankvorgang } from "../api"
import { summe } from "../format"
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
jahr: "Jahr",
monat: "Monat",
woche: "Woche",
tag: "Tag",
}
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
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)
d.setHours(0, 0, 0, 0)
if (art === "woche") d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
return d
}
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
const start = zeitraumStart(art, jetzt)
return fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) >= start)
}
export function tankSeit(
tankvorgaenge: readonly Tankvorgang[],
art: Zeitraum,
jetzt = new Date(),
): Tankvorgang[] {
const start = zeitraumStart(art, jetzt)
return tankvorgaenge.filter((t) => t.liters != null && new Date(t.ts) >= start)
}
/**
* Liegt der Zeitpunkt in der Nachtspanne? Die Spanne darf über Mitternacht
* gehen (22:00 bis 06:00) — dann gilt sie außerhalb statt innerhalb.
*/
export function istNachtZeit(zeitpunkt: string | Date, von: string, bis: string): boolean {
const d = zeitpunkt instanceof Date ? zeitpunkt : new Date(zeitpunkt)
const uhrzeit = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
return von > bis ? uhrzeit >= von || uhrzeit < bis : uhrzeit >= von && uhrzeit < bis
}
export interface Langzeitverbrauch {
/** null, wenn kein plausibler Wert bestimmbar ist. */
wert: number | null
km: number
liter: number
bis: Date | null
basis: "Kilometerstand an den Tankvorgängen" | "erfasste Fahrten" | ""
/** Ein Wert kam heraus, lag aber außerhalb des Plausibilitätsfensters. */
unplausibel: boolean
}
/**
* Alle je getankten Liter gegen die Strecke bis zum letzten Tankstop.
*
* Bewusst nicht „Liter des Zeitraums geteilt durch km des Zeitraums": das ergab
* unmögliche Werte, sobald eine Fahrt fehlte oder ein Tankvorgang noch
* unvollständig war. Werte außerhalb von 3 bis 30 l/100 km gelten als
* unbekannt statt als Tatsache.
*/
export function langzeitverbrauch(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
): Langzeitverbrauch {
const tanks = tankvorgaenge
.filter((t) => t.liters != null)
.slice()
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime())
if (tanks.length === 0) {
return { wert: null, km: 0, liter: 0, bis: null, basis: "", unplausibel: false }
}
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 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"
} else {
km = summe(
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
(f) => f.distance_km ?? 0,
)
basis = "erfasste Fahrten"
}
const roh = km > 0 ? (liter / km) * 100 : null
const gueltig = roh !== null && roh >= 3 && roh <= 30
return {
wert: gueltig ? roh : null,
km,
liter,
bis,
basis,
unplausibel: roh !== null && !gueltig,
}
}
export interface Jahresauswertung {
jahr: number
kmJahr: number
stundenJahr: number
literJahr: number
kostenJahr: number
tagKm: number
nachtKm: number
arbeitKm: number
privatKm: number
arbeitFahrten: number
privatFahrten: number
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
langzeit: Langzeitverbrauch
}
export function jahresauswertung(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
nachtVon: string,
nachtBis: string,
jetzt = new Date(),
): Jahresauswertung {
const jeZeitraum = {} as Jahresauswertung["jeZeitraum"]
for (const art of ZEITRAEUME) {
const f = fahrtenSeit(fahrten, art, jetzt)
const t = tankSeit(tankvorgaenge, art, jetzt)
jeZeitraum[art] = {
km: summe(f, (x) => x.distance_km ?? 0),
stunden: summe(f, (x) => x.duration_s ?? 0) / 3600,
liter: summe(t, (x) => x.liters ?? 0),
kosten: summe(t, (x) => x.fuel_total_eur ?? 0),
}
}
const fJahr = fahrtenSeit(fahrten, "jahr", jetzt)
const kmJahr = jeZeitraum.jahr.km
const tagKm = summe(
fJahr.filter((f) => !istNachtZeit(f.ts_start, nachtVon, nachtBis)),
(f) => f.distance_km ?? 0,
)
const arbeit = fJahr.filter((f) => f.art === "arbeitsweg")
const arbeitKm = summe(arbeit, (f) => f.distance_km ?? 0)
return {
jahr: jetzt.getFullYear(),
kmJahr,
stundenJahr: jeZeitraum.jahr.stunden,
literJahr: jeZeitraum.jahr.liter,
kostenJahr: jeZeitraum.jahr.kosten,
tagKm,
nachtKm: kmJahr - tagKm,
arbeitKm,
privatKm: kmJahr - arbeitKm,
arbeitFahrten: arbeit.length,
privatFahrten: fJahr.length - arbeit.length,
jeZeitraum,
langzeit: langzeitverbrauch(fahrten, tankvorgaenge),
}
}
/* ------------------------------------------------- Gruppierung für Listen */
export interface MonatsGruppe<T> {
/** Format YYYY-MM, damit sich absteigend sortieren lässt. */
schluessel: string
jahr: number
monat: number
eintraege: T[]
}
export interface JahresGruppe<T> {
jahr: number
monate: MonatsGruppe<T>[]
eintraege: T[]
}
/**
* Gruppiert nach Jahr und Monat, beide absteigend — das Neueste zuerst,
* so wie es die Listenansichten von Fahrten und Tankvorgängen zeigen.
*/
export function nachJahrUndMonat<T>(
eintraege: readonly T[],
zeitpunktVon: (eintrag: T) => string,
): JahresGruppe<T>[] {
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
const sortiert = eintraege
.slice()
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
for (const eintrag of sortiert) {
const d = new Date(zeitpunktVon(eintrag))
if (Number.isNaN(d.getTime())) continue
const jahr = d.getFullYear()
const monat = d.getMonth()
const schluessel = `${jahr}-${String(monat + 1).padStart(2, "0")}`
let monate = jahre.get(jahr)
if (!monate) {
monate = new Map()
jahre.set(jahr, monate)
}
let gruppe = monate.get(schluessel)
if (!gruppe) {
gruppe = { schluessel, jahr, monat, eintraege: [] }
monate.set(schluessel, gruppe)
}
gruppe.eintraege.push(eintrag)
}
return [...jahre.entries()]
.sort((a, b) => b[0] - a[0])
.map(([jahr, monate]) => {
const sortierteMonate = [...monate.values()].sort((a, b) => b.monat - a.monat)
return {
jahr,
monate: sortierteMonate,
eintraege: sortierteMonate.flatMap((m) => m.eintraege),
}
})
}