Selbst-Update-Sicherung, Service-Prognose ohne Servicebuch und Sicherheit neu gestaltet

- aktualisierung.py: Backup-Ordner verliert sein manifest.json beim Sichern,
  sonst laedt HA ihn nach einem Neustart als zweite audi_dashboard-Integration
  (Ursache des raetselhaften "gleicher Pfad"-Umbenennungs-Fehlers).
- service.ts/audi-dashboard-app.js: Uebersicht faellt jetzt wie Service schon
  auf die Fahrzeugmeldung zurueck, wenn kein Servicebucheintrag existiert;
  eigenes (kuerzeres) Oelwechsel-Intervall wird jetzt auch ohne Servicebuch-
  eintrag naeherungsweise aus der Herstellermeldung hochgerechnet, statt
  weiterhin unveraendert die Herstellervorgabe zu zeigen.
- Sicherheit neu gestaltet: vier Sammelzeilen (Fahrzeug verriegelt / Tueren
  und Klappen geschlossen / Fenster und Dach geschlossen / Kein Licht) statt
  einer flachen Liste, mit neuen Tuerschloss-, Dach- und Standlicht-Sensor-
  rollen, Kreis-Haekchen-Symbolen und einer Detailseite je Tuer/Motorhaube/
  Kofferraum in beiden Frontends.

Details, Verifikation und Sensor-Rollen in AGENTS.md (Abschnitte J/M/N).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-24 15:29:37 +02:00
co-authored by Claude Sonnet 5
parent 146f809e9b
commit 5a048488ea
23 changed files with 866 additions and 97 deletions
+50 -39
View File
@@ -1,19 +1,24 @@
/**
* Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel.
*
* Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend:
* `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt
* dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage
* unbekannt, sobald eine einzige Prüfung unbekannt ist.
* Vier Sammelzeilen statt der früheren flachen Liste: Fahrzeug verriegelt /
* Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht
* ausgeschaltet - jede fasst mehrere Punkte aus `_sicherheitscheck` über
* deren `gruppe`-Feld zusammen (siehe `gruppenErgebnis()` in bausteine.tsx).
* Entscheidend bleibt: `ok === null` heißt „Sensor meldet nichts" und wird
* als unbekannt dargestellt — nie als grün geraten. Nur "Türen und Klappen
* geschlossen" führt zu einer Detailseite (TuerenKlappen.tsx) mit jeder Tür,
* Motorhaube und dem Kofferraum einzeln.
*/
import { StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datumZeit } from "../format"
import { Leerzustand } from "./bausteine"
import type { SeitenName } from "../navigation"
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
export function Fahrzeugstatus() {
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { fahrzeug, statusStand } = useDaten()
if (!fahrzeug) return null
@@ -27,8 +32,10 @@ export function Fahrzeugstatus() {
)
}
const unbekannt = punkte.filter((p) => p.ok === null).length
const offen = punkte.filter((p) => p.ok === false)
const verriegelt = gruppenErgebnis(punkte.filter((p) => p.gruppe === "verriegelt"))
const tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen"))
const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach"))
const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht"))
return (
<>
@@ -36,45 +43,49 @@ export function Fahrzeugstatus() {
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
title={
fahrzeug.gesichert === true
? "Alles verschlossen"
? "Fahrzeug ist sicher abgestellt"
: fahrzeug.gesichert === false
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
? "Bitte prüfen"
: "Zustand nicht vollständig bekannt"
}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
/>
<Tile>
<span className="ads-eyebrow">Einzelprüfungen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
<div className="dm-sicherheitsliste">
<div className="dm-sicherheitszeile">
<StatusKreis ok={verriegelt} />
<span className="dm-sicherheitszeile__text">
{verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"}
</span>
</div>
<button type="button" className="dm-sicherheitszeile" onClick={() => geheZu("tuerenklappen")}>
<StatusKreis ok={tuerenKlappen} />
<span className="dm-sicherheitszeile__text">
{tuerenKlappen === null
? "Türen/Klappen unbekannt"
: tuerenKlappen
? "Türen und Klappen geschlossen"
: "Türen oder Klappen offen"}
</span>
<svg className="dm-sicherheitszeile__chev" viewBox="0 0 6 10" width="6" height="10" aria-hidden="true">
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
</button>
<div className="dm-sicherheitszeile">
<StatusKreis ok={fensterDach} />
<span className="dm-sicherheitszeile__text">
{fensterDach === null ? "Fenster/Dach unbekannt" : fensterDach ? "Fenster und Dach geschlossen" : "Fenster oder Dach offen"}
</span>
</div>
<div className="dm-sicherheitszeile">
<StatusKreis ok={licht} />
<span className="dm-sicherheitszeile__text">
{licht === null ? "Licht unbekannt" : licht ? "Kein Licht" : "Licht eingeschaltet"}
</span>
</div>
</div>
</Tile>
{unbekannt > 0 && (
<p className="dm-fussnote">
{unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das
Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als
unbekannt statt als sicher.
</p>
)}
</>
)
}
+10 -6
View File
@@ -18,6 +18,7 @@ import {
letzterInspektion,
letzterOelwechsel,
meldungsPrognose,
oelMeldungsPrognose,
oelwechselPrognose,
} from "../daten/service"
import { datum, de, isoTag } from "../format"
@@ -70,11 +71,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
? `${de(prognose.restKm)} km · ${datum(prognose.datum)}`
: null
// Ganz ohne Servicebucheintrag (prognose null) übernimmt die
// Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet mit
// der echten Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate.
const oelMeldungProg = oelHatMeldung && !prognose
? meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten)
: null
// Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei Herstellervorgabe
// unverändert, bei eigenem (kürzerem) Intervall über
// oelMeldungsPrognose()/meldungsPrognoseEigenesIntervall() auf das eigene
// Intervall hochgerechnet, statt es bis zum ersten Servicebucheintrag zu
// ignorieren (siehe deren Kommentare in service.ts).
const oelMeldungProg = oelHatMeldung && !prognose ? oelMeldungsPrognose(fahrzeug, fahrten) : null
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben wäre nur eine zweite,
// verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim eigenen
@@ -84,7 +86,9 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
? `voraussichtlich am ${datum(prognose.datum)}`
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
: oelMeldungProg
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
? fahrzeug.oel.modus === "hersteller" || oelMeldungProg.restKm == null
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
: `${de(oelMeldungProg.restKm)} km, voraussichtlich am ${datum(oelMeldungProg.datum)}`
: null
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
// einem kürzeren eigenen Intervall (fahrzeug.oel.modus) - die Inspektion hat
@@ -0,0 +1,52 @@
/**
* Detailseite zu "Türen und Klappen geschlossen" - jede Tür einzeln,
* Motorhaube, Kofferraum. Einzige der vier Sammelzeilen aus Fahrzeugstatus.tsx
* mit eigener Detailseite. Vorlage: die frühere flache Liste in
* Fahrzeugstatus.tsx (vor der Aufteilung in vier Sammelzeilen).
*/
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { Leerzustand } from "./bausteine"
export function TuerenKlappen() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const punkte = fahrzeug.sicherheitscheck.filter((p) => p.gruppe === "tueren_klappen")
if (punkte.length === 0) {
return (
<Leerzustand
titel="Keine Zustandsdaten"
text="Das Fahrzeug meldet derzeit keine Tür- oder Klappendaten. Sobald es das tut, stehen sie hier."
/>
)
}
return (
<Tile>
<span className="ads-eyebrow">Türen und Klappen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
</Tile>
)
}
+47
View File
@@ -7,6 +7,8 @@ import type { ReactNode } from "react"
import { ActionButton, Tile } from "@audi-dash/ui"
import type { SicherheitsPunkt } from "../api"
/**
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
* Hinweis, was als Nächstes passiert — nie nur „keine Daten".
@@ -94,3 +96,48 @@ export function Werteliste({ kinder }: { kinder: ReactNode }) {
export function bestaetigen(frage: string): boolean {
return window.confirm(frage)
}
/**
* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt)
* statt eines einfarbigen Punkts, für die Sicherheit-Seiten (Fahrzeugstatus,
* TuerenKlappen) - Zustand ist so schon am Symbol selbst erkennbar, nicht nur
* an der Farbe (Vorlage vom Nutzer geliefert).
*/
export function StatusKreis({ ok }: { ok: boolean | null }) {
const klasse =
ok === null ? "dm-statuskreis dm-statuskreis--warn" : ok ? "dm-statuskreis" : "dm-statuskreis dm-statuskreis--bad"
return (
<span className={klasse} aria-hidden="true">
<svg viewBox="0 0 16 16" width="13" height="13">
{ok === null ? (
<path
d="M5.7 6a2.3 2.3 0 1 1 2.7 2.27c-.5.09-.7.5-.7 1.03v.2M8 11.6v.01"
stroke="#fff"
strokeWidth="1.5"
strokeLinecap="round"
fill="none"
/>
) : ok ? (
<path
d="M3 8.3 6.2 11.5 13 4.5"
stroke="#fff"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path d="M4 4l8 8M12 4l-8 8" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" />
)}
</svg>
</span>
)
}
/** Fasst ok über eine Gruppe zusammen: unbekannt, sobald ein Punkt unbekannt
ist oder die Gruppe ganz ohne Einträge dasteht (kein Sensor dieser Rolle
zugeordnet) - nie true durch eine leere Liste erschlichen. */
export function gruppenErgebnis(punkte: readonly SicherheitsPunkt[]): boolean | null {
if (punkte.length === 0 || punkte.some((p) => p.ok === null)) return null
return punkte.every((p) => p.ok === true)
}
+4 -1
View File
@@ -18,6 +18,7 @@ import { Service, Servicebuch, Werkstatt } from "./Service"
import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken"
import { TuerenKlappen } from "./TuerenKlappen"
import { Uebersicht } from "./Uebersicht"
import {
Beitrag,
@@ -60,7 +61,9 @@ export function SeiteFuer(props: SeitenProps) {
case "home":
return <Uebersicht geheZu={geheZu} />
case "sicherheit":
return <Fahrzeugstatus />
return <Fahrzeugstatus geheZu={geheZu} />
case "tuerenklappen":
return <TuerenKlappen />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "ident":
+20 -3
View File
@@ -99,11 +99,28 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
expect(geheZu).not.toHaveBeenCalled()
})
it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
it("zeigt eine Gruppe mit unbekanntem Punkt als unbekannt, nicht als sicher", async () => {
const { container } = zeige("sicherheit")
await waitFor(() => expect(container.querySelector(".dm-sicherheitsliste")).not.toBeNull())
// Motorhaube (ok: null) steckt in "tueren_klappen" - die ganze Gruppe
// gilt damit als unbekannt, nicht als sicher geraten.
expect(screen.getByText("Türen/Klappen unbekannt")).toBeTruthy()
})
it("Sicherheit: Türen und Klappen navigiert zur Detailseite, die anderen Zeilen nicht", async () => {
const geheZu = vi.fn()
const { container } = zeige("sicherheit", undefined, geheZu)
await waitFor(() => expect(container.querySelector(".dm-sicherheitsliste")).not.toBeNull())
fireEvent.click(screen.getByText("Türen/Klappen unbekannt"))
expect(geheZu).toHaveBeenCalledWith("tuerenklappen")
})
it("Türen und Klappen: zeigt nur die Punkte dieser Gruppe", async () => {
const { container } = zeige("tuerenklappen")
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
expect(screen.getByText("unbekannt")).toBeTruthy()
expect(screen.getByText("Motorhaube")).toBeTruthy()
expect(screen.queryByText("Standlicht")).toBeNull()
})
it("gruppiert Fahrten nach Jahr", async () => {