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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:29:37 +02:00
parent 146f809e9b
commit 5a048488ea
23 changed files with 866 additions and 97 deletions
+4 -1
View File
@@ -88,10 +88,13 @@ export interface Tankvorgang {
edited_fields?: string[];
}
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */
/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt".
"gruppe" fasst mehrere Punkte zu einer Sammelzeile zusammen (verriegelt /
tueren_klappen / fenster_dach / licht), siehe Fahrzeugstatus.tsx. */
export interface SicherheitsPunkt {
label: string;
ok: boolean | null;
gruppe: "verriegelt" | "tueren_klappen" | "fenster_dach" | "licht";
}
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
@@ -94,7 +94,7 @@ describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
tankprozent: 62,
reichweite_km: 385,
gesichert: true,
sicherheitscheck: [{ label: "Tür vorne links", ok: true }],
sicherheitscheck: [{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" }],
})
expect(f.odo).toBe(48250)
expect(f.odoBekannt).toBe(true)
+94
View File
@@ -5,7 +5,9 @@ import {
kmProTagAusFahrten,
letzterOelwechsel,
meldungsPrognose,
meldungsPrognoseEigenesIntervall,
naechsterService,
oelMeldungsPrognose,
oelwechselPrognose,
} from "./service"
import type { Fahrzeug } from "./profilAdapter"
@@ -252,4 +254,96 @@ describe("meldungsPrognose", () => {
it("liefert nichts ohne jede brauchbare Angabe", () => {
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
})
it("gibt die Restkilometer mit zurück", () => {
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
expect(p!.restKm).toBe(5000)
})
})
describe("meldungsPrognoseEigenesIntervall", () => {
const jetzt = new Date("2026-08-11T12:00:00")
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("rechnet Restkilometer und Zeitgrenze auf das eigene, kürzere Intervall um", () => {
// Herstellermeldung: 25.000 km / 24 Monate Rest bis zum 2027-08-11 -
// daraus ergibt sich ein angenommener letzter Service vor 2025-08-11.
// Eigenes Intervall (15.000 km / 12 Monate) ab demselben Punkt: Zeit-
// grenze 2026-08-11 (heute), Restkilometer 25000 - 30000 + 15000 = 10000.
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, fahrten, jetzt,
)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(10000)
// 10.000 km bei 30 km/Tag läge weit in der Zukunft - die zurückgerechnete
// eigene Zeitgrenze (heute) greift zuerst.
expect(p!.durchZeitlimit).toBe(true)
expect(p!.datum).toBe("2026-08-11")
})
it("deckelt die Restkilometer bei null, wenn das eigene Intervall rechnerisch schon überschritten ist", () => {
// Erst 10.000 von 30.000 km seit der Herstellerfälligkeit gefahren -
// das kürzere eigene Intervall (15.000 km) ist rechnerisch längst fällig.
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 10000, 30000, 24, 15000, 12, fahrten, jetzt,
)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(0)
expect(p!.durchZeitlimit).toBe(false)
})
it("fällt ohne Fahrtenlog auf die zurückgerechnete eigene Zeitgrenze zurück", () => {
const p = meldungsPrognoseEigenesIntervall(
"2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, [], jetzt,
)
expect(p).not.toBeNull()
expect(p!.datum).toBe("2026-08-11")
expect(p!.durchZeitlimit).toBe(true)
})
it("liefert nichts ohne bekannte Herstellerintervall-Länge", () => {
expect(
meldungsPrognoseEigenesIntervall("2027-08-11T00:00:00+00:00", 25000, null, 24, 15000, 12, fahrten, jetzt),
).toBeNull()
})
})
describe("oelMeldungsPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
const fahrten = [
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
]
it("übernimmt bei Herstellervorgabe unverändert die reine Fahrzeugmeldung", () => {
const f = fahrzeug(10000, "hersteller")
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(25000)
expect(p!.datum).toBe("2027-08-11")
})
it("rechnet bei eigenem Intervall auf dessen kürzere Vorgabe um", () => {
const f = fahrzeug(10000, "eigen")
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(10000)
expect(p!.datum).toBe("2026-08-11")
})
it("fällt bei eigenem Intervall ohne Herstellerwerte auf die reine Meldung zurück", () => {
const f = fahrzeug(10000, "eigen")
f.oel.herstellerKm = null
f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00"
f.oelwechselFaelligKm = 25000
const p = oelMeldungsPrognose(f, fahrten, jetzt)
expect(p!.restKm).toBe(25000)
expect(p!.datum).toBe("2027-08-11")
})
})
+69 -7
View File
@@ -154,6 +154,8 @@ export interface Meldungsprognose {
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
km-Grenze rechtzeitig zu erreichen. */
durchZeitlimit: boolean
/** Restkilometer bis zur Fälligkeit, falls bekannt. */
restKm: number | null
}
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
@@ -169,14 +171,74 @@ export function meldungsPrognose(
jetzt = new Date(),
): Meldungsprognose | null {
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null
const restKm = Math.abs(faelligKm)
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false }
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
const rate = kmProTagAusFahrten(fahrten, jetzt)
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit }
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
}
/** Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die
Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in
"Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service
wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall-
Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und
das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet -
eine Näherung (der wahre letzte Service kann näher oder ferner liegen),
aber ohne sie würde die eigene Intervallwahl bis zum ersten
Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */
export function meldungsPrognoseEigenesIntervall(
faelligTs: string | null,
faelligKm: number | null,
herstellerKm: number | null,
herstellerMonate: number | null,
eigenKm: number | null,
eigenMonate: number | null,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
if (faelligKm == null || !herstellerKm || eigenKm == null) return null
const restKm = Math.max(0, Math.abs(faelligKm) - herstellerKm + eigenKm)
const zeitDatum =
faelligTs && herstellerMonate != null && eigenMonate != null
? monatePlus(faelligTs, eigenMonate - herstellerMonate)
: null
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm }
const rate = kmProTagAusFahrten(fahrten, jetzt)
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm }
}
/** Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei
Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe),
bei eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall()
hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne
Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst
(kein Sensor dafür), fällt das sanft auf die reine Meldung zurück. */
export function oelMeldungsPrognose(
fahrzeug: Fahrzeug,
fahrten: readonly Fahrtdaten[],
jetzt = new Date(),
): Meldungsprognose | null {
const oel = fahrzeug.oel
const roh = () => meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
if (oel.modus === "hersteller") return roh()
const eigen = meldungsPrognoseEigenesIntervall(
fahrzeug.oelwechselFaelligTs,
fahrzeug.oelwechselFaelligKm,
oel.herstellerKm,
oel.herstellerMonate,
oel.km,
oel.monate,
fahrten,
jetzt,
)
return eigen ?? roh()
}
/* ------------------------------------------------------ Nächster Service */
@@ -219,8 +281,8 @@ export function naechsterService(
if (oel) {
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
} else {
const m = meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
if (m) kandidaten.push({ art: "Ölwechsel", restKm: fahrzeug.oelwechselFaelligKm, datum: m.datum })
const m = oelMeldungsPrognose(fahrzeug, fahrten, jetzt)
if (m) kandidaten.push({ art: "Ölwechsel", restKm: m.restKm, datum: m.datum })
}
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
@@ -228,7 +290,7 @@ export function naechsterService(
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
} else {
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
if (m) kandidaten.push({ art: "Inspektion", restKm: fahrzeug.inspektionFaelligKm, datum: m.datum })
if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum })
}
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
+3
View File
@@ -33,6 +33,7 @@ export type SeitenName =
| "fill"
// Sonstige
| "sicherheit"
| "tuerenklappen"
| "live"
| "einst"
@@ -64,6 +65,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
sbuch: "service",
einst: "home",
sicherheit: "home",
tuerenklappen: "sicherheit",
live: "home",
}
@@ -90,6 +92,7 @@ export const TITEL: Record<SeitenName, string> = {
trip: "Fahrt",
fill: "Tankvorgang",
sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen",
live: "Fahrt läuft",
einst: "Einstellungen",
}
+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 () => {
+42
View File
@@ -486,6 +486,48 @@
border: 1.5px solid var(--fg3);
}
/* Kreis-Häkchen für die vier Sammelzeilen der Sicherheit-Seite - Zustand ist
so schon am Symbol erkennbar, nicht nur an der Farbe (siehe .dm-punkt
oben, das bleibt für die Detailliste unverändert). */
.dm-statuskreis {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--ok);
}
.dm-statuskreis--warn { background: var(--warn); }
.dm-statuskreis--bad { background: var(--bad); }
.dm-statuskreis svg { display: block; }
.dm-sicherheitsliste {
display: flex;
flex-direction: column;
}
.dm-sicherheitszeile {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 12px 0;
border-bottom: 1px solid var(--line);
width: 100%;
background: none;
border-left: none;
border-right: none;
border-top: none;
font: inherit;
color: inherit;
text-align: left;
}
.dm-sicherheitszeile:last-child { border-bottom: 0; }
button.dm-sicherheitszeile { cursor: pointer; }
button.dm-sicherheitszeile:active { background: var(--tile-2); }
.dm-sicherheitszeile__text { flex: 1; font-size: 14.5px; }
.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); }
/* ------------------------------------------------------------ Galerie */
.dm-galerie {
+6 -4
View File
@@ -13,10 +13,12 @@ export const beispielStatus: Fahrzeugstatus = {
batteriespannung: null,
gesichert: true,
sicherheitscheck: [
{ label: "Tür vorne links", ok: true },
{ label: "Tür vorne rechts", ok: true },
{ label: "Fenster vorne links", ok: false },
{ label: "Motorhaube", ok: null },
{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" },
{ label: "Tür vorne rechts", ok: true, gruppe: "tueren_klappen" },
{ label: "Motorhaube", ok: null, gruppe: "tueren_klappen" },
{ label: "Fenster vorne links", ok: false, gruppe: "fenster_dach" },
{ label: "Türschloss vorne links", ok: true, gruppe: "verriegelt" },
{ label: "Standlicht", ok: true, gruppe: "licht" },
],
oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00",
oelwechsel_faellig_km: 11750,