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:
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user