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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user