Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige
Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene Reaktionen verlangen. Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage brachte: 1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt und sicherheit. Das Backend schreibt tankprozent, gesichert und sicherheitscheck - die Oberflaeche haette still ueberall undefined gelesen, ohne dass irgendetwas fehlgeschlagen waere. 2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen, den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu ueberschreiben. Abschnitte werden jetzt kopiert. Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests laufen. Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf, vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Zahlen, Geld, Datum und Dauer im deutschen Format. Aus dem alten Panel
|
||||
* übernommen (`de()`/`eur()`), damit App und Panel identisch formatieren.
|
||||
*/
|
||||
|
||||
export function de(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return Number(wert ?? 0).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: nachkomma,
|
||||
maximumFractionDigits: nachkomma,
|
||||
})
|
||||
}
|
||||
|
||||
export function eur(wert: number | null | undefined): string {
|
||||
return de(wert, 2)
|
||||
}
|
||||
|
||||
/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */
|
||||
export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string {
|
||||
return wert === null || wert === undefined ? "—" : de(wert, nachkomma)
|
||||
}
|
||||
|
||||
/** Summe über eine Liste. */
|
||||
export function summe<T>(liste: readonly T[], auswahl: (eintrag: T) => number): number {
|
||||
return liste.reduce((zwischenstand, eintrag) => zwischenstand + (auswahl(eintrag) || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchschnittspreis je Liter — immer mengengewichtet (Summe Kosten geteilt
|
||||
* durch Summe Liter), nie als Mittel der Einzelpreise. Eine kleine Tankung zu
|
||||
* hohem Preis darf den Schnitt nicht so stark heben wie eine große.
|
||||
*/
|
||||
export function schnittpreis(
|
||||
liste: readonly { liters?: number | null; fuel_total_eur?: number | null }[],
|
||||
): number {
|
||||
const liter = summe(liste, (t) => t.liters ?? 0)
|
||||
if (liter <= 0) return 0
|
||||
return summe(liste, (t) => t.fuel_total_eur ?? 0) / liter
|
||||
}
|
||||
|
||||
const DATUM_KURZ = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
|
||||
|
||||
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
|
||||
if (wert === null || wert === undefined || wert === "") return null
|
||||
const datum = wert instanceof Date ? wert : new Date(wert)
|
||||
return Number.isNaN(datum.getTime()) ? null : datum
|
||||
}
|
||||
|
||||
export function datum(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_KURZ.format(d) : "—"
|
||||
}
|
||||
|
||||
export function datumLang(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? DATUM_LANG.format(d) : "—"
|
||||
}
|
||||
|
||||
export function uhrzeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? UHRZEIT.format(d) : "—"
|
||||
}
|
||||
|
||||
export function datumZeit(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—"
|
||||
}
|
||||
|
||||
export function monatJahr(wert: string | number | Date | null | undefined): string {
|
||||
const d = alsDatum(wert)
|
||||
return d ? MONAT_JAHR.format(d) : "—"
|
||||
}
|
||||
|
||||
/** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */
|
||||
export function dauer(sekunden: number | null | undefined): string {
|
||||
if (!sekunden || sekunden <= 0) return "—"
|
||||
const minutenGesamt = Math.round(sekunden / 60)
|
||||
const stunden = Math.floor(minutenGesamt / 60)
|
||||
const minuten = minutenGesamt % 60
|
||||
if (stunden === 0) return `${minuten} min`
|
||||
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
|
||||
}
|
||||
|
||||
/** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */
|
||||
export function isoTag(wert: string | number | Date | null | undefined = new Date()): string {
|
||||
const d = alsDatum(wert)
|
||||
if (!d) return ""
|
||||
const versatz = d.getTimezoneOffset() * 60_000
|
||||
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
|
||||
}
|
||||
Reference in New Issue
Block a user