Neustart-Festhaenger, OTA-Pruefsumme, Statistik-Kopfzahl (2026.8.31.4)
Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam. Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben, vier Regressionstests dazu. OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als ?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht. Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile, darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich nicht in eine Zeile. Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem Katalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,20 @@ export class ApiFehler extends Error {
|
||||
get istNetzproblem(): boolean {
|
||||
return this.status === null;
|
||||
}
|
||||
|
||||
/** Vorübergehend nicht erreichbar: kein Netz, oder ein Server, der
|
||||
gerade hochfährt.
|
||||
|
||||
502/503/504 sind genau das, was ein Neustart von Home Assistant
|
||||
liefert - der Vorschaltserver antwortet schon, Home Assistant selbst
|
||||
noch nicht. Das ist für die Oberfläche dasselbe wie kein Netz: warten
|
||||
und den zwischengespeicherten Stand zeigen, nicht eine Fehlerzeile
|
||||
stehen lassen, die niemand mehr wegbekommt. 500 gehört bewusst NICHT
|
||||
dazu - das ist ein echter Fehler in der Anfrage, der sich durch
|
||||
Warten nicht bessert. */
|
||||
get istVoruebergehend(): boolean {
|
||||
return this.istNetzproblem || this.status === 502 || this.status === 503 || this.status === 504;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnfrageOptionen {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Was passiert, wenn Home Assistant neu startet.
|
||||
*
|
||||
* Gemeldet vom Eigentümer (2026-08-31): während eines Neustarts zeigte die App
|
||||
* „502 Error“, und die Meldung blieb stehen, nachdem Home Assistant längst
|
||||
* wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide hier
|
||||
* abgesichert:
|
||||
*
|
||||
* 1. 502 galt als harter Ladefehler statt als „Server kommt gerade hoch“.
|
||||
* 2. Nach dem Wiederverbinden wurde genau einmal nachgeladen. Schlug das
|
||||
* fehl (die Websocket-Verbindung steht regelmäßig ein paar Sekunden vor
|
||||
* der REST-Schnittstelle), kam nie wieder ein „verbunden“ — und damit nie
|
||||
* wieder ein Versuch.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { ApiFehler, type DataMetricApi } from "../api"
|
||||
import { DatenAnbieter, useDaten } from "./DatenKontext"
|
||||
import { beispielApi } from "../tests/beispieldaten"
|
||||
|
||||
function Anzeige() {
|
||||
const { bereit, ladefehler } = useDaten()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="bereit">{bereit ? "ja" : "nein"}</span>
|
||||
<span data-testid="fehler">{ladefehler ?? "-"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Eine API, deren `profilLesen` sich auf Kommando wie ein gerade startender
|
||||
Home Assistant verhält: die nächsten n Aufrufe mit 502 abweisen, danach
|
||||
wieder normal antworten.
|
||||
|
||||
Die Fehlschläge werden bewusst gezählt und nicht an der Aufrufnummer
|
||||
festgemacht: der Horcher meldet beim Abonnieren sofort "verbunden", die
|
||||
Zählung liefe sonst schon beim Aufbau los. */
|
||||
function apiMitNeustart() {
|
||||
const echt = beispielApi()
|
||||
let verbindungHorcher: ((z: string) => void) | null = null
|
||||
let offeneFehlschlaege = 0
|
||||
const profilLesen = vi.fn(async () => {
|
||||
if (offeneFehlschlaege > 0) {
|
||||
offeneFehlschlaege -= 1
|
||||
throw new ApiFehler("Bad Gateway", 502)
|
||||
}
|
||||
return echt.profilLesen()
|
||||
})
|
||||
const api = {
|
||||
...echt,
|
||||
profilLesen,
|
||||
live: {
|
||||
...echt.live,
|
||||
aufVerbindung: (h: (z: string) => void) => {
|
||||
verbindungHorcher = h
|
||||
h("verbunden")
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as DataMetricApi
|
||||
return {
|
||||
api,
|
||||
profilLesen,
|
||||
/** Home Assistant startet neu: die nächsten n Ladeversuche laufen ins
|
||||
502, dann meldet sich die Websocket-Verbindung zurück. */
|
||||
neustartMelden: (fehlschlaege: number) => {
|
||||
offeneFehlschlaege = fehlschlaege
|
||||
verbindungHorcher?.("verbunden")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Neustart von Home Assistant", () => {
|
||||
it("meldet 502 nicht als Ladefehler, solange Daten da sind", async () => {
|
||||
const { api, neustartMelden } = apiMitNeustart()
|
||||
render(
|
||||
<DatenAnbieter api={api}>
|
||||
<Anzeige />
|
||||
</DatenAnbieter>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
|
||||
|
||||
neustartMelden(1)
|
||||
|
||||
// Der fehlgeschlagene Versuch darf keine Fehlerzeile hinterlassen.
|
||||
await waitFor(() => expect(screen.getByTestId("fehler").textContent).toBe("-"))
|
||||
expect(screen.getByTestId("bereit").textContent).toBe("ja")
|
||||
})
|
||||
|
||||
it("versucht es nach einem fehlgeschlagenen Nachladen erneut", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
const { api, profilLesen, neustartMelden } = apiMitNeustart()
|
||||
render(
|
||||
<DatenAnbieter api={api}>
|
||||
<Anzeige />
|
||||
</DatenAnbieter>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
|
||||
const vorher = profilLesen.mock.calls.length
|
||||
|
||||
neustartMelden(2)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 1))
|
||||
|
||||
// Ohne Wiederholung bliebe es bei diesem einen Versuch — und die App
|
||||
// auf dem Stand von vor dem Neustart, bis jemand sie beendet.
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 2))
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 3))
|
||||
|
||||
expect(screen.getByTestId("fehler").textContent).toBe("-")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("ApiFehler.istVoruebergehend", () => {
|
||||
it("gilt für kein Netz und für einen startenden Server", () => {
|
||||
expect(new ApiFehler("offline", null).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("bad gateway", 502).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("unavailable", 503).istVoruebergehend).toBe(true)
|
||||
expect(new ApiFehler("timeout", 504).istVoruebergehend).toBe(true)
|
||||
})
|
||||
|
||||
it("gilt nicht für einen echten Serverfehler", () => {
|
||||
// 500 bessert sich durch Warten nicht — das muss sichtbar bleiben.
|
||||
expect(new ApiFehler("kaputt", 500).istVoruebergehend).toBe(false)
|
||||
expect(new ApiFehler("nicht da", 404).istVoruebergehend).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,14 @@ import { eigeneVersion, versionVergleichen } from "./appVersion"
|
||||
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
|
||||
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
|
||||
|
||||
/** Abstaende zwischen zwei Ladeversuchen nach einem Wiederverbinden.
|
||||
|
||||
Wachsend, in Millisekunden, zusammen gut zwei Minuten. Der erste
|
||||
Abstand ist kurz, weil Home Assistant nach einem Neustart meist binnen
|
||||
Sekunden antwortet; die spaeteren sind lang, damit ein wirklich
|
||||
abgestuerzter Server nicht dauernd angefragt wird. */
|
||||
const WIEDERHOLABSTAENDE_MS = [2000, 4000, 8000, 15000, 30000, 60000]
|
||||
|
||||
export interface DatenWert {
|
||||
bereit: boolean
|
||||
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
|
||||
@@ -69,7 +77,10 @@ export interface DatenWert {
|
||||
rohprofil: Profil | null
|
||||
|
||||
api: DataMetricApi
|
||||
neuLaden: () => Promise<void>
|
||||
/** Laedt alles neu; `true`, wenn es geklappt hat. Aufrufer duerfen den
|
||||
Rueckgabewert ignorieren - er ist fuer die Wiederholung nach einem
|
||||
Wiederverbinden da. */
|
||||
neuLaden: () => Promise<boolean>
|
||||
jetztAktualisieren: () => Promise<void>
|
||||
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
|
||||
profilSpeichern: (aenderung: {
|
||||
@@ -113,7 +124,18 @@ export function DatenAnbieter({
|
||||
const rohprofilRef = useRef<Profil | null>(null)
|
||||
rohprofilRef.current = rohprofil
|
||||
|
||||
const neuLaden = useCallback(async () => {
|
||||
// Dasselbe fuer `bereit`: neuLaden liest den Wert nur, darf aber nicht
|
||||
// davon abhaengen. Stand er in den Abhaengigkeiten, bekaeme der Effekt
|
||||
// unten (der bewusst nur an `api` haengt) fuer immer die Fassung vom
|
||||
// ersten Rendern mit `bereit === false` - und wuerde nach einem
|
||||
// Wiederverbinden jeden Aussetzer als harten Ladefehler melden, obwohl
|
||||
// laengst Daten da sind.
|
||||
const bereitRef = useRef(false)
|
||||
bereitRef.current = bereit
|
||||
|
||||
/** Laedt alles neu. Gibt zurueck, ob es geklappt hat - der Aufrufer
|
||||
entscheidet, ob er es noch einmal versucht. */
|
||||
const neuLaden = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const [p, f, t, s] = await Promise.all([
|
||||
api.profilLesen(),
|
||||
@@ -136,18 +158,39 @@ export function DatenAnbieter({
|
||||
setzeServerVersion(versionsangabe?.app ?? null)
|
||||
setzeOtaBuendel(versionsangabe?.buendel ?? null)
|
||||
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
|
||||
return true
|
||||
} catch (fehler) {
|
||||
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
|
||||
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
|
||||
const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem
|
||||
if (!netzproblem || !bereit) {
|
||||
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
|
||||
// Daten da sind — dann zeigt die App den zwischengespeicherten Stand
|
||||
// mit Offline-Hinweis. Dazu zaehlt auch ein Home Assistant, der
|
||||
// gerade neu startet (502/503/504), siehe istVoruebergehend.
|
||||
const wartet = fehler instanceof ApiFehler && fehler.istVoruebergehend
|
||||
if (!wartet || !bereitRef.current) {
|
||||
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [api, bereit])
|
||||
}, [api])
|
||||
|
||||
// Erstladung plus Live-Verbindung.
|
||||
useEffect(() => {
|
||||
let abgemeldet = false
|
||||
let nachladeUhr: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Versucht es so lange, bis eine Ladung durchgeht - mit wachsendem
|
||||
Abstand, damit ein dauerhaft nicht erreichbarer Server nicht im
|
||||
Sekundentakt angefragt wird. Nach gut zwei Minuten ist Schluss;
|
||||
dann hilft nur noch das naechste "verbunden" oder ein Zug nach
|
||||
unten auf der Uebersicht. */
|
||||
const nachladenBisEsKlappt = async (versuch = 0): Promise<void> => {
|
||||
if (abgemeldet) return
|
||||
if (await neuLaden()) return
|
||||
if (abgemeldet || versuch >= WIEDERHOLABSTAENDE_MS.length) return
|
||||
nachladeUhr = setTimeout(() => {
|
||||
void nachladenBisEsKlappt(versuch + 1)
|
||||
}, WIEDERHOLABSTAENDE_MS[versuch])
|
||||
}
|
||||
|
||||
void neuLaden()
|
||||
void api.warteschlange.laden()
|
||||
|
||||
@@ -155,7 +198,15 @@ export function DatenAnbieter({
|
||||
setzeVerbindung(zustand)
|
||||
// Nach einem Wiederverbinden kann während der Trennung etwas passiert
|
||||
// sein, das kein Ereignis mehr ausgelöst hat.
|
||||
if (zustand === "verbunden") void neuLaden()
|
||||
//
|
||||
// Warum das nicht ein einzelner Aufruf sein darf: nach einem Neustart
|
||||
// von Home Assistant steht die Websocket-Verbindung regelmaessig ein
|
||||
// paar Sekunden, bevor die REST-Schnittstelle antwortet - dazwischen
|
||||
// kommt 502. Ein einziger Versuch schlug dann fehl, und weil danach
|
||||
// kein weiteres "verbunden" mehr folgt, blieb die App bis zum
|
||||
// Beenden auf dem Stand von vor dem Neustart stehen, mitsamt der
|
||||
// Fehlerzeile (vom Eigentuemer gemeldet, 2026-08-31).
|
||||
if (zustand === "verbunden") void nachladenBisEsKlappt()
|
||||
})
|
||||
|
||||
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
|
||||
@@ -184,6 +235,8 @@ export function DatenAnbieter({
|
||||
void api.live.starten()
|
||||
|
||||
return () => {
|
||||
abgemeldet = true
|
||||
if (nachladeUhr) clearTimeout(nachladeUhr)
|
||||
abVerbindung()
|
||||
abZustand()
|
||||
abWarteschlange()
|
||||
|
||||
@@ -83,6 +83,29 @@ export function buendelPasst(buendel: Buendelangabe | null, eigene: string | nul
|
||||
|
||||
export class OtaFehler extends Error {}
|
||||
|
||||
/**
|
||||
* Erklärt einen fehlgeschlagenen Download, statt die Meldung des Plugins
|
||||
* durchzureichen.
|
||||
*
|
||||
* „Checksum" heißt: die geladenen Bytes sind nicht die erwarteten. In diesem
|
||||
* Projekt war die Ursache bisher nie ein kaputtes Bündel (Ablage, Auslieferung
|
||||
* und Prüfsumme sind nachweislich deckungsgleich), sondern ein Download, der
|
||||
* mitten in einen Neustart von Home Assistant lief. Das ist keine Sackgasse,
|
||||
* sondern ein Grund, es gleich noch einmal zu versuchen - und genau das soll
|
||||
* dort stehen, wo der Fehler steht.
|
||||
*/
|
||||
function ladefehlerText(fehler: unknown): string {
|
||||
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
|
||||
if (/checksum|prüfsumme|pruefsumme/i.test(meldung)) {
|
||||
return (
|
||||
"Das Update kam unvollständig an. Das passiert, wenn Home Assistant " +
|
||||
"während des Ladens neu startet. Sobald es wieder läuft, noch einmal " +
|
||||
`versuchen. (${meldung})`
|
||||
)
|
||||
}
|
||||
return `Das Update konnte nicht geladen werden (${meldung}).`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Bündel und schaltet darauf um.
|
||||
*
|
||||
@@ -100,11 +123,21 @@ export async function buendelAnwenden(
|
||||
): Promise<never> {
|
||||
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
|
||||
|
||||
const url = new URL(buendel.url, basisUrl).href
|
||||
// Die Fassung hängt als Abfrageteil an der Adresse.
|
||||
//
|
||||
// Home Assistant liefert /audi_dashboard_static/ ohne Cache-Control, aber
|
||||
// mit ETag und Last-Modified (nachgemessen). Genau dann darf jeder Zwischen-
|
||||
// speicher - der des Geräts wie einer im Weg - die Datei nach eigenem
|
||||
// Ermessen eine Weile behalten. Käme dabei die vorherige Zip zurück, während
|
||||
// die Prüfsumme aus der Entität längst die neue nennt, scheiterte jedes
|
||||
// Update mit einem Prüfsummenfehler, ohne dass irgendetwas kaputt wäre.
|
||||
// Dieselbe Vorsorge trifft das Panel seit jeher für sein eigenes Bündel.
|
||||
const adresse = new URL(buendel.url, basisUrl)
|
||||
adresse.searchParams.set("v", buendel.version)
|
||||
let geladen
|
||||
try {
|
||||
geladen = await CapacitorUpdater.download({
|
||||
url,
|
||||
url: adresse.href,
|
||||
version: buendel.version,
|
||||
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
|
||||
// Zip und verwirft sie bei Abweichung (calcChecksum in
|
||||
@@ -113,9 +146,7 @@ export async function buendelAnwenden(
|
||||
checksum: buendel.sha256,
|
||||
})
|
||||
} catch (fehler) {
|
||||
throw new OtaFehler(
|
||||
`Das Update konnte nicht geladen werden (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
|
||||
)
|
||||
throw new OtaFehler(ladefehlerText(fehler))
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,8 +12,17 @@ import { summe } from "../format"
|
||||
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
|
||||
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
|
||||
|
||||
/** Alle Zeiträume, die berechnet werden - einschließlich "absolut". */
|
||||
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
|
||||
|
||||
/** Die Spalten, die eine Kachel zeigt.
|
||||
|
||||
"absolut" fehlt hier bewusst: der Gesamtwert steht seit dem 2026-08-31
|
||||
als große Zahl in der Kopfzeile der Kachel (Ansage des Eigentümers). Eine
|
||||
fünfte Spalte passte auf dem Telefon ohnehin nicht in eine Zeile - die
|
||||
Kachel ist dort innen 307px breit, ein sechsstelliger Wert 67px. */
|
||||
export const ZEITRAUM_SPALTEN: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
|
||||
|
||||
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
|
||||
jahr: "Jahr",
|
||||
monat: "Monat",
|
||||
@@ -146,6 +155,11 @@ export interface Jahresauswertung {
|
||||
stundenJahr: number
|
||||
literJahr: number
|
||||
kostenJahr: number
|
||||
/** Über den gesamten erfassten Zeitraum - die Zahl in der Kopfzeile. */
|
||||
kmGesamt: number
|
||||
stundenGesamt: number
|
||||
literGesamt: number
|
||||
kostenGesamt: number
|
||||
tagKm: number
|
||||
nachtKm: number
|
||||
arbeitKm: number
|
||||
@@ -206,6 +220,10 @@ export function jahresauswertung(
|
||||
stundenJahr: jeZeitraum.jahr.stunden,
|
||||
literJahr: jeZeitraum.jahr.liter,
|
||||
kostenJahr: jeZeitraum.jahr.kosten,
|
||||
kmGesamt: jeZeitraum.absolut.km,
|
||||
stundenGesamt: jeZeitraum.absolut.stunden,
|
||||
literGesamt: jeZeitraum.absolut.liter,
|
||||
kostenGesamt: jeZeitraum.absolut.kosten,
|
||||
tagKm,
|
||||
nachtKm: kmJahr - tagKm,
|
||||
arbeitKm,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useDaten } from "../daten/DatenKontext"
|
||||
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
|
||||
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
|
||||
import { datum, datumZeit, isoTag } from "../format"
|
||||
import { useBoden, useTheme } from "../theme"
|
||||
import { useTheme } from "../theme"
|
||||
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
|
||||
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
|
||||
import { DatensatzPopup } from "./DatensatzPopup"
|
||||
@@ -49,7 +49,6 @@ export function Einstellungen({
|
||||
neuLaden,
|
||||
} = useDaten()
|
||||
const [theme, themeSetzen] = useTheme()
|
||||
const [boden, setzeBoden] = useBoden()
|
||||
// "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den
|
||||
// Knopf darunter (einrichtenOffen in vEinst()).
|
||||
const [einrichtenOffen, setzeEinrichtenOffen] = useState(false)
|
||||
@@ -356,12 +355,6 @@ export function Einstellungen({
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Aus zeigt nur die Symbole und gibt etwas Höhe frei
|
||||
</span>
|
||||
<Feld label="Fahrbahn unter dem Fahrzeug" last>
|
||||
<Switch checked={boden} onChange={setzeBoden} aria-label="Fahrbahn unter dem Fahrzeug" />
|
||||
</Feld>
|
||||
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
|
||||
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
|
||||
</span>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useState, type ReactNode } from "react"
|
||||
import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
|
||||
import { ZEITRAUM_NAME, ZEITRAUM_SPALTEN, jahresauswertung } from "../daten/statistik"
|
||||
import type { Zeitraum } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
@@ -105,15 +105,18 @@ export function Statistik() {
|
||||
// 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]])
|
||||
ZEITRAUM_SPALTEN.map((z) => [auswahl(z), ZEITRAUM_NAME[z]])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Die große Zahl ist der Gesamtwert, nicht der des laufenden Jahres
|
||||
- deshalb steht im Titel auch keine Jahreszahl mehr. Die vier
|
||||
Spalten darunter bleiben die Zeiträume. */}
|
||||
<StatKachel
|
||||
titel={`Distanz und Fahrzeit ${a.jahr}`}
|
||||
wert={de(a.kmJahr)}
|
||||
titel="Distanz und Fahrzeit"
|
||||
wert={de(a.kmGesamt)}
|
||||
einheit="km"
|
||||
neben={`${de(a.stundenJahr)} h`}
|
||||
neben={`${de(a.stundenGesamt)} h`}
|
||||
>
|
||||
<Vierergruppe
|
||||
titel="Gefahrene Distanz [km]"
|
||||
@@ -128,10 +131,10 @@ export function Statistik() {
|
||||
</StatKachel>
|
||||
|
||||
<StatKachel
|
||||
titel={`Getankte Menge und Kosten ${a.jahr}`}
|
||||
wert={de(a.literJahr)}
|
||||
titel="Getankte Menge und Kosten"
|
||||
wert={de(a.literGesamt)}
|
||||
einheit="l"
|
||||
neben={`${de(a.kostenJahr)} €`}
|
||||
neben={`${de(a.kostenGesamt)} €`}
|
||||
>
|
||||
<Vierergruppe
|
||||
titel="Getankte Liter [l]"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useDaten } from "../daten/DatenKontext"
|
||||
import { naechsterService } from "../daten/service"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { useBoden } from "../theme"
|
||||
import { BILDPLAETZE, bildInfo } from "./bilder"
|
||||
import { Blattzeile, Leerzustand } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
@@ -30,17 +29,12 @@ const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
|
||||
"Inspektion": SymbolInspektion,
|
||||
}
|
||||
|
||||
/* Innenraumaufnahmen bekommen keinen Bodenschatten — `OHNE_SZENE` im Panel. */
|
||||
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]
|
||||
|
||||
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten()
|
||||
const [boden] = useBoden()
|
||||
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
|
||||
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
|
||||
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
|
||||
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
|
||||
siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
|
||||
@@ -50,12 +44,6 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
|
||||
// im Panel — das fehlte hier ganz.
|
||||
const startbild = bildInfo(platz, winterraeder)
|
||||
const bodenKlasse =
|
||||
!boden || OHNE_SZENE.includes(platz.name)
|
||||
? ""
|
||||
: winterraeder
|
||||
? " dm-szene--winter"
|
||||
: " dm-szene--strasse"
|
||||
|
||||
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
|
||||
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
|
||||
@@ -104,7 +92,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
Typenschild, Kennzeichen und Statuszeile — dieselbe Aufteilung wie
|
||||
vHome() im Panel (`.szene.bleed` gefolgt von einer `.tile.flat`).
|
||||
Der Status stand hier bisher als eigenes Element unter der Szene. */}
|
||||
<div className={`dm-szene${bodenKlasse}`}>
|
||||
<div className="dm-szene">
|
||||
<Bild
|
||||
datei={startbild.datei}
|
||||
beschreibung={startbild.beschreibung}
|
||||
|
||||
@@ -269,49 +269,29 @@ input[type="date"].dm-eingabe {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.dm-szene::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
|
||||
nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
|
||||
Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
|
||||
darunter (gemeldet vom Eigentuemer, 2026-08-31). */
|
||||
height: 26%;
|
||||
background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
/* Der Ausblend-Verlauf ist am 2026-08-31 entfallen: er lag ueber dem
|
||||
unteren Teil des Fotos, und der Eigentuemer hat zweimal darum gebeten,
|
||||
das Fahrzeug unverdeckt zu sehen. Ebenso der Bodenschatten, der hier
|
||||
frueher als .dm-szene--strasse::after stand - samt dem Schalter dafuer. */
|
||||
|
||||
/* Bodenschatten unter dem Fahrzeug (`.szene.strasse::after` /
|
||||
`.szene.winter::after` im Panel, abschaltbar über den Schalter „Boden" in
|
||||
den Einstellungen). */
|
||||
.dm-szene--strasse::after,
|
||||
.dm-szene--winter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
bottom: 6px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dm-szene--winter::after {
|
||||
background: radial-gradient(ellipse at center, rgba(150, 190, 255, .16), transparent 70%);
|
||||
}
|
||||
/* Das Fahrzeugfoto wird nicht mehr beschnitten.
|
||||
|
||||
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
|
||||
nicht das 4:3-Seitenverhältnis, das ImagePlaceholder sonst für Galerien
|
||||
verwendet (dort korrekt, hier war es das nie - "carfix klein" im Panel ist
|
||||
16:8). Scoped auf die Übersicht-Szene, damit andere ImagePlaceholder-
|
||||
Stellen (Mein Audi, Reifen, ...) ihre eigene, richtige Größe behalten. */
|
||||
Gemessen am echten Foto (2026-08-31): es ist 2000x1125 gross, also 16:9.
|
||||
Der Kasten stand auf 16:8 - bei object-fit:cover wurde das Bild auf die
|
||||
Kastenbreite skaliert und oben wie unten je ein Viertel abgeschnitten.
|
||||
Das Dach lag darin.
|
||||
|
||||
Statt das Verhaeltnis auf 16:9 festzuschreiben - was beim naechsten Foto in
|
||||
einem anderen Format wieder schneiden wuerde - bestimmt jetzt das Bild
|
||||
selbst die Hoehe. Der Platzhalter behaelt sein festes Verhaeltnis, er hat
|
||||
ja keines von sich aus. */
|
||||
.dm-szene .ads-bildbox {
|
||||
aspect-ratio: 16 / 8;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.dm-szene .ads-bildbox img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Ohne Foto bleibt die Fläche flach und läuft nicht randlos: sonst stünden
|
||||
@@ -685,9 +665,25 @@ button.dm-serviceblock:active {
|
||||
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. */
|
||||
/* Vier feste Spalten - Jahr, Monat, Woche, Tag muessen in EINE Zeile passen.
|
||||
|
||||
Vorher stand hier repeat(auto-fit, minmax(84px, 1fr)). Auf dem Telefon ist
|
||||
die Kachel innen 307px breit, damit passten bei 12px Abstand nur drei
|
||||
Spalten (3 x 84 + 2 x 12 = 276; vier waeren 372) - die vierte rutschte in
|
||||
eine zweite Zeile.
|
||||
|
||||
Mit vier festen Spalten bleiben 67,75px je Spalte. Der laengste realistisch
|
||||
vorkommende Wert ist ein sechsstelliger Kilometerstand wie "209.177": am
|
||||
laufenden Panel gemessen 67px in Audi Type Wide 300 bei 16px. Es passt
|
||||
also, aber ohne Reserve - eine fuenfte Spalte gaebe es nur mit kleinerer
|
||||
Schrift, und genau deshalb steht der Gesamtwert jetzt in der Kopfzeile der
|
||||
Kachel statt in einer eigenen Spalte.
|
||||
|
||||
Bewusst kein white-space:nowrap: waere ein Wert doch einmal breiter,
|
||||
umbricht er innerhalb seiner Spalte, statt aus der Kachel zu laufen. */
|
||||
.dm-quad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
@@ -209,5 +209,10 @@ export function beispielApi(): DataMetricApi {
|
||||
tankvorgangAktualisieren: async () => null,
|
||||
tankvorgangLoeschen: async () => null,
|
||||
jetztAktualisieren: async () => null,
|
||||
// Die echte Fassung schluckt ihre Fehler selbst und liefert dann null.
|
||||
// Fehlte sie hier ganz, endete jede Ladung im Fangzweig von neuLaden -
|
||||
// die Seiten rendern trotzdem, aber ein Test ueber den Ladefehler waere
|
||||
// wertlos.
|
||||
appVersionAngabeLesen: async () => null,
|
||||
} as unknown as DataMetricApi
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState`
|
||||
* an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie;
|
||||
* die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar
|
||||
* wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte
|
||||
* denselben Fehler.
|
||||
* wurde die Wahl erst nach einem Neustart der App. Die uebrigen Schalter
|
||||
* hatten denselben Fehler.
|
||||
*
|
||||
* Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten,
|
||||
* eine schaltet, die andere zeigt an.
|
||||
@@ -14,26 +14,26 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { useBoden, useTheme } from "./theme"
|
||||
import { useTabBeschriftung, useTheme } from "./theme"
|
||||
|
||||
function Anzeige() {
|
||||
const [theme] = useTheme()
|
||||
const [boden] = useBoden()
|
||||
const [beschriftung] = useTabBeschriftung()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="boden">{boden ? "an" : "aus"}</span>
|
||||
<span data-testid="beschriftung">{beschriftung ? "an" : "aus"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Schalter() {
|
||||
const [theme, setzeTheme] = useTheme()
|
||||
const [boden, setzeBoden] = useBoden()
|
||||
const [beschriftung, setzeBeschriftung] = useTabBeschriftung()
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setzeTheme(theme === "nacht" ? "tag" : "nacht")}>Thema</button>
|
||||
<button onClick={() => setzeBoden(!boden)}>Boden</button>
|
||||
<button onClick={() => setzeBeschriftung(!beschriftung)}>Beschriftung</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,17 +57,17 @@ describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => {
|
||||
expect(screen.getByTestId("theme").textContent).not.toBe(vorher)
|
||||
})
|
||||
|
||||
it("der Boden-Schalter der einen Komponente erreicht die andere", () => {
|
||||
it("der Beschriftungs-Schalter der einen Komponente erreicht die andere", () => {
|
||||
render(
|
||||
<>
|
||||
<Anzeige />
|
||||
<Schalter />
|
||||
</>,
|
||||
)
|
||||
const vorher = screen.getByTestId("boden").textContent
|
||||
const vorher = screen.getByTestId("beschriftung").textContent
|
||||
|
||||
fireEvent.click(screen.getByText("Boden"))
|
||||
fireEvent.click(screen.getByText("Beschriftung"))
|
||||
|
||||
expect(screen.getByTestId("boden").textContent).not.toBe(vorher)
|
||||
expect(screen.getByTestId("beschriftung").textContent).not.toBe(vorher)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
*
|
||||
* useTheme() → App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter),
|
||||
* Typenschild.tsx (zweimal, für die Farbe des Schilds)
|
||||
* useBoden() → Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige)
|
||||
* useTabBeschriftung() → Einstellungen.tsx (der Schalter), App.tsx (die Leiste)
|
||||
*
|
||||
* Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und
|
||||
* den Speicher der Ablage — die Kopie in App.tsx blieb stehen, `data-theme`
|
||||
* wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App.
|
||||
* Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter
|
||||
* hatte denselben Fehler, nur ist er weniger auffällig.
|
||||
* Genau so gemeldet: „Tag/Nacht Umschaltung wirkungslos". Die übrigen
|
||||
* Schalter hatten denselben Fehler, nur ist er dort weniger auffällig.
|
||||
*
|
||||
* `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt
|
||||
* je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein
|
||||
@@ -95,11 +95,6 @@ const THEMA = einstellung<Theme>(
|
||||
(wert) => wert,
|
||||
)
|
||||
|
||||
const BODEN = einstellung<boolean>(
|
||||
"dm360.boden",
|
||||
(roh) => roh !== "aus",
|
||||
(an) => (an ? "an" : "aus"),
|
||||
)
|
||||
|
||||
const TABBESCHRIFTUNG = einstellung<boolean>(
|
||||
"dm360.tabbeschriftung",
|
||||
@@ -129,16 +124,10 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
|
||||
return [theme, setzen]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodenschatten unter dem Fahrzeugbild — im Panel der Schalter „Boden" in den
|
||||
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
|
||||
* Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an.
|
||||
*/
|
||||
export function useBoden(): [boolean, (an: boolean) => void] {
|
||||
const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand)
|
||||
const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), [])
|
||||
return [an, setzen]
|
||||
}
|
||||
/* useBoden() ist am 2026-08-31 entfallen: der Bodenschatten unter dem
|
||||
Fahrzeug ist weg (Ansage des Eigentuemers), damit hatte der Schalter
|
||||
nichts mehr zu schalten. Der abgelegte Wert unter "dm360.boden" bleibt
|
||||
liegen und stoert nicht - er wird schlicht nicht mehr gelesen. */
|
||||
|
||||
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
|
||||
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
|
||||
|
||||
Reference in New Issue
Block a user