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:
2026-08-31 15:35:56 +02:00
parent 4af38d7e65
commit 90efcc5b00
19 changed files with 565 additions and 161 deletions
+14
View File
@@ -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)
})
})
+61 -8
View File
@@ -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()
+36 -5
View File
@@ -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 {
+18
View File
@@ -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,
+1 -8
View File
@@ -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>
+11 -8
View File
@@ -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]"
+1 -13
View File
@@ -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}
+36 -40
View File
@@ -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);
}
+5
View File
@@ -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
}
+11 -11
View File
@@ -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)
})
})
+7 -18
View File
@@ -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] {