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:
@@ -1,6 +1,8 @@
|
||||
# AGENTS.md — Project state, review findings, open items, and working rules
|
||||
|
||||
**Last updated: 2026-08-30** (owner findings batch - vehicle pin, tile chevrons, select arrows,
|
||||
**Last updated: 2026-08-31** (Neustart-Festhänger der App, OTA-Prüfsumme, Statistik-Kopfzahl,
|
||||
Setup-Filter und die gemessene Wahrheit über die Dongle-Konfiguration; Manifest `2026.8.31.4`,
|
||||
siehe Abschnitte AT und AU. Davor: 2026-08-30 (owner findings batch - vehicle pin, tile chevrons, select arrows,
|
||||
SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.19`, see sections AO, AP and AQ.
|
||||
Before that: design-audit follow-through - the five priority items applied to
|
||||
both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN.
|
||||
@@ -6050,6 +6052,143 @@ rendering bug in a view.
|
||||
|
||||
---
|
||||
|
||||
## AT. Neustart-Festhänger, OTA-Prüfsumme, Statistik-Kopfzahl, Setup-Filter (2026.8.31.4)
|
||||
|
||||
### Die App blieb nach einem Neustart von Home Assistant hängen
|
||||
|
||||
Gemeldet: während Update und Neustart zeigte die App „502 Error" unter einem längst
|
||||
gedrückten „Jetzt neu starten", und die Meldung blieb stehen, nachdem Home Assistant
|
||||
wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide in
|
||||
`DatenKontext.tsx`:
|
||||
|
||||
1. **502 galt als harter Ladefehler.** `ApiFehler.istNetzproblem` ist nur `status === null`;
|
||||
ein Vorschaltserver, der antwortet, während Home Assistant noch hochfährt, liefert
|
||||
aber 502/503/504. Neu: `istVoruebergehend` fasst beides zusammen (500 bewusst **nicht** —
|
||||
das bessert sich durch Warten nicht). Solange schon Daten da sind, erscheint keine
|
||||
Fehlerzeile mehr.
|
||||
2. **Nach dem Wiederverbinden wurde genau einmal nachgeladen.** Die Websocket-Verbindung
|
||||
steht regelmäßig ein paar Sekunden vor der REST-Schnittstelle. Schlug dieser eine
|
||||
Versuch fehl, kam nie wieder ein `"verbunden"` — und damit nie wieder ein Versuch; die
|
||||
App blieb bis zum Beenden auf dem Stand von vor dem Neustart, mitsamt Fehlerzeile.
|
||||
Neu: `nachladenBisEsKlappt()` mit wachsenden Abständen (2/4/8/15/30/60 s, zusammen gut
|
||||
zwei Minuten), sauber abgeräumt beim Verlassen.
|
||||
|
||||
Dazu ein Fehler, den dieselbe Stelle verdeckt hat: `neuLaden` hing an `bereit`, der Effekt
|
||||
darunter aber nur an `api` — die dort festgehaltene Fassung sah `bereit` für immer als
|
||||
`false`. `bereit` liegt jetzt in einem Ref, `neuLaden` ist stabil. Vier Regressionstests
|
||||
(`DatenKontext.test.tsx`), gegen den alten Stand als fehlschlagend nachgewiesen.
|
||||
|
||||
Panel-seitig kein Gegenstück nötig: es hängt an Home Assistants eigener Verbindung, deren
|
||||
Wiederanlauf schon in Abschnitt N behandelt ist.
|
||||
|
||||
### „App-Update: failed to download / checksum failed"
|
||||
|
||||
**Das Bündel ist nachweislich in Ordnung.** Geprüft von Ende zu Ende: der Blob bei
|
||||
`HEAD`, die Datei auf der Platte und die tatsächlich über HTTP ausgelieferten Bytes
|
||||
haben alle denselben SHA-256, den auch `bundle.json` nennt; git behandelt die Zip als
|
||||
binär (`i/-text`), `core.autocrlf` kann ihr nichts anhaben. Die Prüfsumme, die das
|
||||
Plugin vergleicht, ist genau dieser Wert (`CapgoUpdater.swift`, `calcChecksum` über die
|
||||
heruntergeladene Zip vor dem Entpacken).
|
||||
|
||||
Der Fehler fiel zusammen mit dem Neustart — ein Download, der mitten hineinläuft, liefert
|
||||
je nach Zeitpunkt einen Abbruch („failed to download") oder unvollständige Bytes
|
||||
(„checksum"). Zwei Änderungen in `ota.ts`:
|
||||
|
||||
- Die Fassung hängt jetzt als `?v=` an der Bündel-Adresse. Home Assistant liefert
|
||||
`/audi_dashboard_static/` **ohne** `Cache-Control`, aber mit `ETag` und
|
||||
`Last-Modified` (nachgemessen) — genau der Fall, in dem jeder Zwischenspeicher nach
|
||||
eigenem Ermessen eine Weile die alte Datei behalten darf. Dieselbe Vorsorge trifft das
|
||||
Panel seit jeher für sein eigenes Bündel.
|
||||
- Ein Prüfsummenfehler wird nicht mehr wortwörtlich durchgereicht, sondern erklärt: „Das
|
||||
Update kam unvollständig an … sobald Home Assistant wieder läuft, noch einmal
|
||||
versuchen."
|
||||
|
||||
### Statistik: der Gesamtwert steht in der Kopfzeile
|
||||
|
||||
Erst sollte „Absolut" eine fünfte Spalte links werden. Am laufenden Panel gemessen passt
|
||||
das nicht: die Kachel ist auf dem Telefon innen **307px** breit, ein sechsstelliger
|
||||
Kilometerstand wie `209.177` misst in Audi Type Wide 300 **67px** bei 16px und 54px bei
|
||||
13px — fünf Spalten gingen nur mit kleinerer Schrift. Der Eigentümer hat daraufhin
|
||||
entschieden, den Gesamtwert dorthin zu setzen, wo man ihn zuerst liest: **die große Zahl
|
||||
oben in „Distanz und Fahrzeit" und „Getankte Menge und Kosten" ist jetzt der Gesamtwert,
|
||||
nicht mehr der des laufenden Jahres** — die Jahreszahl im Titel entfällt entsprechend.
|
||||
Darunter bleiben vier Spalten (Jahr/Monat/Woche/Tag), jetzt als `repeat(4, minmax(0,1fr))`
|
||||
statt `auto-fit`, das auf dem Telefon nur drei Spalten zuließ und die vierte umbrach.
|
||||
`ZEITRAEUME` rechnet weiter alle fünf, angezeigt wird `ZEITRAUM_SPALTEN`.
|
||||
|
||||
### Kein Schatten, kein Beschnitt — und kein Schalter mehr dafür
|
||||
|
||||
Gemessen statt geschätzt: das Foto ist 2000×1125 (16:9), der Kasten stand auf 16:8 und
|
||||
`object-fit: cover` schnitt oben und unten je ein Viertel ab — das Dach lag darin. Statt
|
||||
das Verhältnis auf 16:9 festzuschreiben (was beim nächsten Foto in einem anderen Format
|
||||
wieder schneiden würde) bestimmt jetzt das Bild selbst die Höhe; der Platzhalter behält
|
||||
sein festes Verhältnis, er hat keines von sich aus. Nachgemessen: Kasten 367×207 zu
|
||||
2000×1125 — kein Beschnitt.
|
||||
|
||||
Gleichzeitig sind der Ausblend-Verlauf und der Bodenschatten entfallen (beide Codebasen,
|
||||
inklusive der `height:220px`-Regel im iOS-Overlay, die den Beschnitt sonst auf breiten
|
||||
Schirmen wieder erzwungen hätte). Damit hatte der Schalter **„Fahrbahn unter dem
|
||||
Fahrzeug"** nichts mehr zu schalten und ist ebenfalls weg — samt `bodenAn`/`szeneKlasse()`
|
||||
im Panel und `useBoden`/`bodenKlasse`/`OHNE_SZENE` in der App. Der Regressionstest für
|
||||
den geteilten Einstellungsspeicher (`theme.test.tsx`) prüft das jetzt an
|
||||
`useTabBeschriftung` statt an `useBoden` — dieselbe Aussage, ein noch vorhandener Schalter.
|
||||
|
||||
### Setup: „Nur passende Sensoren" sucht nach dem Signalnamen
|
||||
|
||||
Domain und Einheit lassen bei einem Fahrzeug mit siebzig Sensoren immer noch Dutzende
|
||||
übrig. Der Name, der neben der Überschrift als „(z. B. …)" steht, ist der tatsächliche
|
||||
Signalname der Integration (`beispiel` in `FELDER`) und trifft genau einen. Er filtert
|
||||
jetzt mit — **mit Rückfall**: findet er nichts, bleibt die ungefilterte Liste stehen, denn
|
||||
er stammt aus dieser einen Installation. Zusätzlich zählt er in `entitaetScore()` mit 8
|
||||
Punkten, damit auch der Vorschlag für ein leeres Feld davon profitiert.
|
||||
|
||||
## AU. Der Dongle: was die Konfiguration wirklich bewirkt (2026-08-31)
|
||||
|
||||
Nachgemessen an der echten Aufzeichnung im Testcontainer (7 bzw. 30 Tage Recorder), nicht
|
||||
aus der Konfigurationsanzeige abgelesen.
|
||||
|
||||
**Was gut funktioniert.** Beim Fahren liefert das Gerät alle **10 s** (p90) einen Satz —
|
||||
das ist die eingestellte `Min Period` für „Moving", und sie gilt für Geschwindigkeit wie
|
||||
für Position gleichermaßen (771 bzw. 1165 Punkte in 7 Tagen). Für Strecke, Route,
|
||||
Durchschnitt und vmax reicht das: in denselben 7 Tagen wurde ein Höchstwert von 118 km/h
|
||||
erfasst. `can_fuel_volume` löst **0,1 l** auf (Werte wie 18,9 / 19,1 / 19,5 über 30 Tage;
|
||||
in einzelnen Wochen sieht man nur ganze Liter, das ist die Meldung des Fahrzeugs, nicht
|
||||
die Auflösung des Sensors) — die Grenze `MIN_STRECKE_VERBRAUCH_KM = 3.0` bleibt damit
|
||||
richtig hergeleitet. Der Füllstand in Prozent springt in 1-%-Schritten.
|
||||
|
||||
**Was nicht funktioniert: das rohe Zündsignal als Fahrterkennung.**
|
||||
`engine_ignition_or_acc_status` ist der ungefilterte Zündungseingang. In der Aufzeichnung:
|
||||
am 29.08. drei Wechsel innerhalb von **30 Millisekunden**, danach ein Aus/Ein-Paar mit
|
||||
**10 Sekunden** Abstand — und am 30./31.08. dreizehn Stunden am Stück „an", ohne Fahrt.
|
||||
Solange die App eine Pausentoleranz hatte, hat die das aufgefangen. Seit deren Entfernung
|
||||
(Abschnitt AS) trennt jeder solche Aussetzer eine Fahrt in zwei.
|
||||
|
||||
**Die Begründung für das Entfernen war falsch.** Der `Ignition OFF Timeout` von 900 s
|
||||
unter *Trip \ Odometer* verzögert **nicht** dieses Signal, sondern ausschließlich das
|
||||
geräteeigene Trip-Element ([Teltonika-Wiki](https://wiki.teltonika-gps.com/view/FMM003_Trip/Odometer_settings):
|
||||
„timeout value to detect Trip end once the Ignition … is off"). Es gab also nie zwei
|
||||
Zeitgeber hintereinander — es gab unseren, und daneben einen ungenutzten des Geräts.
|
||||
|
||||
**Die Empfehlung, noch nicht umgesetzt (Entscheidung des Eigentümers):**
|
||||
`ZUENDUNG_SENSOR` auf
|
||||
`binary_sensor.…_trip_status_true_if_trip_started_false_if_stopped` legen. Dieses Signal
|
||||
|
||||
- beginnt erst, wenn Zündung **und** Bewegung **und** `Start Speed` zusammenkommen —
|
||||
dreizehn Stunden Standlicht-Zündung erzeugen damit keine Fahrt,
|
||||
- endet erst 900 s nach Zündung aus — die Pausentoleranz kommt vom Gerät, ohne dass wir
|
||||
einen zweiten Zeitgeber bauen,
|
||||
- ist millisekundenfestes Prellen los.
|
||||
|
||||
Der Preis: das Fahrtende liegt 15 Minuten hinter dem tatsächlichen. Das war mit der
|
||||
eigenen Pausentoleranz genauso und ist damit kein Rückschritt; wer es genauer will, müsste
|
||||
das Ende nachträglich auf den letzten Punkt mit Geschwindigkeit > 0 zurückrechnen — eigenes
|
||||
Arbeitspaket, hier bewusst nicht angefangen.
|
||||
|
||||
**Weiter offen, unabhängig davon:** echte Funklöcher. Im gemessenen Zeitraum gab es
|
||||
mitten in der Fahrt eine Lücke von **2739 s** ohne einen einzigen Satz. Dagegen hilft keine
|
||||
Einstellung am Gerät; das ist der in Abschnitt P/W beschriebene Fall, den die
|
||||
Plausibilitätsgrenze (`UNPLAUSIBLE_KMH`) abfängt.
|
||||
|
||||
## Working conventions (observed — keep them)
|
||||
|
||||
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"}
|
||||
{"version":"2026.8.31.4","sha256":"48d4965f198c06dbcad882c76eca656de6cc8c18a17c7ff24a39c5c9a1e8c5a9","bytes":266032,"gebaut":"2026-08-31T13:30:52Z"}
|
||||
Binary file not shown.
@@ -368,12 +368,12 @@ function markenlogo() {
|
||||
<span class="ph">${esc(label)}</span>
|
||||
</span>`;
|
||||
}
|
||||
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"];
|
||||
function szeneKlasse(i) {
|
||||
if (!bodenAn) return "";
|
||||
if (OHNE_SZENE.includes(BILDER[i].name)) return "";
|
||||
return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse";
|
||||
}
|
||||
/* Der angedeutete Boden unter dem Fahrzeug ist am 2026-08-31 entfallen -
|
||||
der Eigentuemer wollte den Schatten weg, und damit hatte auch der
|
||||
Schalter dafuer nichts mehr zu schalten. Frueher stand hier
|
||||
szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter"
|
||||
an .szene haengte; die zugehoerigen ::after-Regeln sind in
|
||||
audi-dashboard.css beschrieben. */
|
||||
function bildWeiter() {
|
||||
bild = (bild + 1) % BILDER.length;
|
||||
const img = ROOT.getElementById("carimg");
|
||||
@@ -387,7 +387,7 @@ function bildWeiter() {
|
||||
box.querySelector(".platzhalter-label").textContent = info.label;
|
||||
}
|
||||
const sz = ROOT.getElementById("szene");
|
||||
if (sz) sz.className = "szene " + szeneKlasse(bild);
|
||||
if (sz) sz.className = "szene";
|
||||
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild));
|
||||
}
|
||||
|
||||
@@ -1654,7 +1654,7 @@ function vHome() {
|
||||
zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px
|
||||
hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */
|
||||
return `
|
||||
<div class="szene bleed ${szeneKlasse(startIndex())}"><div class="carbox">${carImg("fest")}</div></div>
|
||||
<div class="szene bleed"><div class="carbox">${carImg("fest")}</div></div>
|
||||
<div class="tile flat" style="margin-bottom:20px">
|
||||
${badge()}
|
||||
<span class="label" style="margin-top:10px">${esc(CONFIG.kennzeichen)}</span>
|
||||
@@ -1815,7 +1815,7 @@ function vTuerenKlappen() {
|
||||
function vAudi() {
|
||||
const r = CAR.reifen, a = r[r.aktiv.toLowerCase()];
|
||||
return `
|
||||
<div class="szene ${szeneKlasse(bild)}" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
|
||||
<div class="szene" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
|
||||
<div class="tile flat" style="margin:14px 0 16px">
|
||||
${badge()}
|
||||
<span class="label" style="margin-top:12px">${esc(CONFIG.kennzeichen)}</span>
|
||||
@@ -2793,7 +2793,6 @@ const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift";
|
||||
let beschriftung = (() => {
|
||||
try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; }
|
||||
})();
|
||||
let bodenAn = true;
|
||||
function csvDatei(name, kopf, zeilen) {
|
||||
const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n");
|
||||
const a = document.createElement("a");
|
||||
@@ -2858,10 +2857,6 @@ function vEinst() {
|
||||
<label for="schrift">Beschriftung in der Menüleiste</label>
|
||||
<label class="switch"><input id="schrift" type="checkbox" data-schrift ${beschriftung ? "checked" : ""}><span class="track"></span></label></div>
|
||||
<span class="label" style="margin-top:14px">Aus zeigt nur die Symbole und gibt etwas Höhe frei</span>
|
||||
<div class="feld" style="margin-top:16px;border-bottom:0">
|
||||
<label for="boden">Fahrbahn unter dem Fahrzeug</label>
|
||||
<label class="switch"><input id="boden" type="checkbox" data-boden ${bodenAn ? "checked" : ""}><span class="track"></span></label></div>
|
||||
<span class="label" style="margin-top:14px">Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten</span>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Bild der Übersicht</span>
|
||||
<div class="feld" style="border-bottom:0"><label for="startbild">Ansicht</label>
|
||||
@@ -3040,6 +3035,10 @@ function entitaetScore(feld, e, suchtext) {
|
||||
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
|
||||
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
|
||||
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
|
||||
// Der Signalname aus dem Katalog ("z. B. can_fuel_volume") ist der
|
||||
// treffsicherste Hinweis, den es gibt - deutlich mehr wert als ein
|
||||
// einzelnes Stichwort.
|
||||
if (feld.beispiel && hay.includes(feld.beispiel.toLowerCase())) score += 8;
|
||||
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
|
||||
return score;
|
||||
}
|
||||
@@ -3048,7 +3047,7 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
|
||||
const feld = setupKatalog().find((f) => f.key === feldKey);
|
||||
if (!feld) return [];
|
||||
const text = (suchtext || "").trim().toLowerCase();
|
||||
const liste = ALLE_ENTITAETEN.filter((e) => {
|
||||
let liste = ALLE_ENTITAETEN.filter((e) => {
|
||||
if (e.id === aktuellerWert) return true;
|
||||
if (setupNurPassend) {
|
||||
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
|
||||
@@ -3065,6 +3064,27 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
|
||||
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
|
||||
return true;
|
||||
});
|
||||
/* "Nur passende Sensoren" sucht zusaetzlich nach dem Namen, der neben der
|
||||
Ueberschrift als "(z. B. ...)" steht - dem tatsaechlichen Signalnamen der
|
||||
Integration, nicht einem ausgedachten Beispiel (siehe FELDER in
|
||||
einstellungen.py). Domain und Einheit allein lassen bei einem Fahrzeug
|
||||
mit siebzig Sensoren immer noch Dutzende uebrig; der Signalname trifft
|
||||
genau einen.
|
||||
|
||||
Mit Rueckfall: findet der Name nichts, bleibt die ungefilterte Liste
|
||||
stehen. Eine leere Auswahl waere schlimmer als eine lange - der Beispiel-
|
||||
name stammt aus dieser einen Installation, eine andere kann ihre Signale
|
||||
anders nennen. */
|
||||
if (setupNurPassend && feld.beispiel) {
|
||||
const beispiel = feld.beispiel.toLowerCase();
|
||||
const genau = liste.filter(
|
||||
(e) =>
|
||||
e.id === aktuellerWert ||
|
||||
e.id.toLowerCase().includes(beispiel) ||
|
||||
e.name.toLowerCase().includes(beispiel.replace(/_/g, " ")),
|
||||
);
|
||||
if (genau.length) liste = genau;
|
||||
}
|
||||
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
|
||||
return liste.slice(0, 60);
|
||||
}
|
||||
@@ -3647,6 +3667,13 @@ function vStat() {
|
||||
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
|
||||
const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut");
|
||||
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
|
||||
// Die grosse Zahl oben in den beiden Kacheln zeigt seit dem 2026-08-31 den
|
||||
// Gesamtwert, nicht mehr den des laufenden Jahres (Ansage des Eigentuemers).
|
||||
// Damit steht "Absolut" dort, wo man es zuerst liest, und die Spalten
|
||||
// darunter bleiben die vier Zeitraeume - fuenf passten auf dem Telefon
|
||||
// nachweislich nicht in eine Zeile.
|
||||
const kmAbs = sum(fAbs, (t) => t.distance_km), hAbs = sum(fAbs, (t) => t.duration_s) / 3600;
|
||||
const lAbs = sum(tAbs, (f) => f.liters), eAbs = sum(tAbs, (f) => f.fuel_total_eur || 0);
|
||||
const tJahr = tankSeit("jahr");
|
||||
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
|
||||
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
|
||||
@@ -3655,12 +3682,12 @@ function vStat() {
|
||||
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
|
||||
const lv = langzeitverbrauch();
|
||||
return `
|
||||
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
|
||||
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"], [de(sum(fAbs, (t) => t.distance_km)), "Absolut"]]) +
|
||||
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"], [de(sum(fAbs, (t) => t.duration_s) / 3600), "Absolut"]]))}
|
||||
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
|
||||
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"], [de(sum(tAbs, (f) => f.liters)), "Absolut"]]) +
|
||||
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))}
|
||||
${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h",
|
||||
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
|
||||
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"]]))}
|
||||
${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €",
|
||||
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
|
||||
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))}
|
||||
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
|
||||
`<dl class="rows">
|
||||
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
|
||||
@@ -3668,7 +3695,7 @@ function vStat() {
|
||||
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
||||
</dl>
|
||||
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
|
||||
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"], [schnittFahrtVerbrauch(fAbs), "Absolut"]])}
|
||||
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
|
||||
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
|
||||
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
|
||||
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
|
||||
@@ -5773,7 +5800,6 @@ function ereignisseVerdrahten() {
|
||||
}
|
||||
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||||
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||||
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
|
||||
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
|
||||
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
|
||||
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
|
||||
|
||||
@@ -349,7 +349,9 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
|
||||
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
|
||||
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
|
||||
.bildbox.carfix{height:260px}
|
||||
.bildbox.carfix.klein{height:220px}
|
||||
/* Feste 220px hier wuerden das Foto wieder beschneiden - die Hoehe
|
||||
bestimmt seit dem 2026-08-31 das Bild selbst (siehe audi-dashboard.css). */
|
||||
.bildbox.carfix.klein{height:auto}
|
||||
.bildgrid{grid-template-columns:repeat(4,1fr)}
|
||||
.quad{gap:14px}
|
||||
|
||||
|
||||
@@ -596,30 +596,27 @@ button.leaf:active { background: var(--tile-2); }
|
||||
blendet nach unten in die Fläche aus. */
|
||||
.szene.bleed { margin: -10px -20px 0; padding: 0 0 10px; }
|
||||
.szene.bleed .carbox { padding: 0 8px; }
|
||||
.szene.bleed::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;
|
||||
}
|
||||
.szene.strasse::after, .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%);
|
||||
}
|
||||
.szene.winter::after { background: radial-gradient(ellipse at center, rgba(150,190,255,.16), transparent 70%); }
|
||||
/* Der Ausblend-Verlauf und der Bodenschatten sind am 2026-08-31 entfallen.
|
||||
Beide lagen ueber dem unteren Teil des Fotos, und der Eigentuemer hat
|
||||
zweimal darum gebeten, das Fahrzeug unverdeckt zu sehen. Zurueckholen
|
||||
waere ein linear-gradient auf .szene.bleed::before bzw. ein
|
||||
radial-gradient auf .szene.strasse::after - beides steht in der
|
||||
Versionsgeschichte. */
|
||||
.carbox { position: relative; }
|
||||
.carfix { width: 100%; aspect-ratio: 16 / 9; }
|
||||
.carfix.klein { aspect-ratio: 16 / 8; }
|
||||
/* Das Fahrzeugfoto wird nicht mehr beschnitten.
|
||||
|
||||
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
|
||||
(796x220 Kasten, 796x448 skaliertes Bild). Das Dach lag darin.
|
||||
|
||||
Statt das Seitenverhaeltnis 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. */
|
||||
.bildbox.carfix.klein { aspect-ratio: auto; height: auto; }
|
||||
img.carfix.klein { aspect-ratio: auto; height: auto; }
|
||||
/* Reifenfoto ist oft eine breite Aufnahme; statt beide Ränder symmetrisch
|
||||
wegzuschneiden (object-fit:cover-Standard), bleibt bewusst der rechte Rand
|
||||
des Originalfotos erhalten - dort sitzt das Rad meist im Bild. */
|
||||
@@ -890,7 +887,23 @@ button.leaf:active { background: var(--tile-2); }
|
||||
nebeneinander: bei 307px Innenbreite blieben je 51px, und "209.177" braucht
|
||||
bei 16px Audi Type Wide rund 60. auto-fit bricht deshalb um - auf dem Telefon
|
||||
drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer Reihe. */
|
||||
.quad { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
/* 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. */
|
||||
.quad { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; }
|
||||
.quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; }
|
||||
.bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.31.3",
|
||||
"version": "2026.8.31.4",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
Reference in New Issue
Block a user