Ueberschrittene Servicetermine werden markiert

Wunsch des Eigentuemers: faellige oder ueberschrittene Termine dezent
gelb/orange hinterlegen und mit "ueberschritten" markieren - Service-Seite und
Mein Audi; auf der Uebersicht zusaetzlich die Kennzahl im Warnton.

Erkannt wird am DATUM, nicht an den Restkilometern. Gemessen: die Sensoren
melden -28600 bzw. -22000 bei Faelligkeit 2028 bzw. 2027, negativ heisst hier
also "noch so weit hin". Welches Vorzeichen ein tatsaechlich ueberschrittener
Wert traegt, ist an den vorliegenden Daten nicht beobachtbar - geraten wird es
nicht. Der km-Fall faellt ohnehin unter die Datumsregel: servicePrognose()
setzt datum auf heute, sobald restKm auf 0 laeuft. Der Faelligkeitstag zaehlt
mit ("bereits faellig bzw. ueberschritten").

Dabei eine Luecke im Panel gefunden, vom Test und nicht im Betrieb:
datumAusText() ist verankert und nimmt keine vollen Zeitstempel - die
Fahrzeugmeldung ist aber genau das und bei Oelwechsel und Inspektion die
Hauptquelle. Sie waere still nie als ueberschritten erkannt worden.
zeitstempelAlsDatum() schliesst sie; die App hatte die Luecke nie.

Farbe nach Rolle geteilt wie --bad/--bad-flaeche: --warn ist die Flaeche,
--warn-text die Schrift. Grund: #FF9F0A als Text auf Weiss sind 2.06:1. Auf dem
12% hinterlegten Grund misst --warn-text 4.90:1 (Tag, #A15800) bzw. 9.30:1
(Nacht).

Geprueft: 15 Faelle gegen die echten Panel-Funktionen, 7 in der App, 14
Paritaetsfaelle Panel gegen App, 3 Rendertests ueber alle drei Anzeigestellen.
Gegenprobe: mit ausgehaengter Entscheidung fallen 9 Tests. 327/327, tsc sauber,
Panel als Modul geparst, Manifest und Buendel auf 2026.9.6.6, Container sauber
gestartet.

Offen: optisch nicht gesehen - der Testbestand enthaelt keinen ueberschrittenen
Termin. In OFFEN.md vermerkt.
This commit is contained in:
2026-09-07 13:39:52 +02:00
parent 4d721b6602
commit 27196f9e78
19 changed files with 2874 additions and 2409 deletions
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { terminUeberschritten } from "./service"
/* Beide Codebasen müssen denselben Termin gleich beurteilen - sonst steht auf
der Übersicht "überschritten" und auf derselben Seite im Panel nicht. Geprüft
wird gegen die ECHTE Panel-Funktion, aus der Panel-Quelle herausgeschnitten,
nicht gegen einen Nachbau. Muster wie in paritaet_hu.test.ts. */
const quelle = readFileSync(
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
const block = (kopf: string) => {
const i = quelle.indexOf(kopf)
if (i < 0) throw new Error("fehlt: " + kopf)
let tiefe = 0
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
if (quelle[k] === "{") tiefe++
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
}
throw new Error("unvollstaendig: " + kopf)
}
const zeile = (muster: RegExp) => {
const m = muster.exec(quelle)
if (!m) throw new Error("fehlt: " + muster)
return m[0]
}
const panel = new Function(`
${zeile(/^const DATUM_ISO = .*$/m)}
${zeile(/^const DATUM_TAG = .*$/m)}
${zeile(/^const DATUM_MONAT = .*$/m)}
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
${zeile(/^const DATUM_ZEITSTEMPEL = .*$/m)}
${zeile(/^const gueltigesDatum = .*$/m)}
${block("function datumBauen")}
${block("function datumAusText")}
${block("function zeitstempelAlsDatum")}
${block("function terminUeberschritten")}
return { terminUeberschritten };
`)() as { terminUeberschritten: (...k: (string | Date | null)[]) => boolean }
const tag = (versatz: number) => {
const x = new Date()
x.setDate(x.getDate() + versatz)
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
}
describe("terminUeberschritten: Panel und App urteilen gleich", () => {
const faelle: { name: string; args: (string | null)[] }[] = [
{ name: "gestern", args: [tag(-1)] },
{ name: "heute", args: [tag(0)] },
{ name: "morgen", args: [tag(1)] },
{ name: "ISO vorbei", args: ["2024-03-01"] },
{ name: "ISO Zukunft", args: ["2028-07-23"] },
{ name: "Fahrzeug-Zeitstempel vorbei", args: ["2025-10-21T00:00:00+00:00"] },
{ name: "Fahrzeug-Zeitstempel Zukunft", args: ["2027-10-21T00:00:00+00:00"] },
{ name: "monatsgenau vorbei", args: ["03/2025"] },
{ name: "monatsgenau Zukunft", args: ["03/2099"] },
{ name: "kein Datum", args: [null] },
{ name: "leer", args: [""] },
{ name: "unlesbar", args: ["irgendwas"] },
{ name: "erstes lesbares: null, dann gestern", args: [null, tag(-1)] },
{ name: "erstes lesbares: morgen vor gestern", args: [tag(1), tag(-1)] },
]
for (const f of faelle) {
it(f.name, () => {
expect(terminUeberschritten(...f.args)).toBe(panel.terminUeberschritten(...f.args))
})
}
})
+48 -11
View File
@@ -1,16 +1,6 @@
import { describe, expect, it } from "vitest"
import {
hauptuntersuchungFaellig,
inspektionPrognose,
kmProTagAusFahrten,
letzterOelwechsel,
meldungsPrognose,
meldungsPrognoseEigenesIntervall,
naechsterService,
oelMeldungsPrognose,
oelwechselPrognose,
} from "./service"
import { hauptuntersuchungFaellig, inspektionPrognose, kmProTagAusFahrten, letzterOelwechsel, meldungsPrognose, meldungsPrognoseEigenesIntervall, naechsterService, oelMeldungsPrognose, oelwechselPrognose, terminUeberschritten } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -400,3 +390,50 @@ describe("Hauptuntersuchung", () => {
expect(hauptuntersuchungFaellig([], "keine Ahnung")).toBeNull()
})
})
describe("terminUeberschritten", () => {
const tag = (versatz: number) => {
const x = new Date()
x.setDate(x.getDate() + versatz)
return `${String(x.getDate()).padStart(2, "0")}.${String(x.getMonth() + 1).padStart(2, "0")}.${x.getFullYear()}`
}
it("gestern ist überschritten", () => {
expect(terminUeberschritten(tag(-1))).toBe(true)
})
it("heute zählt mit - 'bereits fällig' ist gemeint", () => {
expect(terminUeberschritten(tag(0))).toBe(true)
})
it("morgen nicht", () => {
expect(terminUeberschritten(tag(1))).toBe(false)
})
it("erkennt den vollen Zeitstempel des Fahrzeugs", () => {
// Genau die Form, die oelwechselFaelligTs/inspektionFaelligTs tragen. Im
// Panel war das die Lücke: dessen Datumsleser ist verankert und nahm sie
// nicht an, die Fahrzeugmeldung wäre dort still nie überschritten gewesen.
expect(terminUeberschritten("2025-10-21T00:00:00+00:00")).toBe(true)
expect(terminUeberschritten("2027-10-21T00:00:00+00:00")).toBe(false)
})
it("versteht monatsgenaue Angaben (Hauptuntersuchung)", () => {
expect(terminUeberschritten("03/2025")).toBe(true)
expect(terminUeberschritten("03/2099")).toBe(false)
})
it("ohne lesbares Datum wird nichts behauptet", () => {
expect(terminUeberschritten(null)).toBe(false)
expect(terminUeberschritten("")).toBe(false)
expect(terminUeberschritten("irgendwas")).toBe(false)
expect(terminUeberschritten(undefined)).toBe(false)
})
it("nimmt das erste LESBARE Datum, nicht das erste beliebige", () => {
// So kann die Markierung nicht von dem abweichen, was daneben steht.
expect(terminUeberschritten(null, tag(-1))).toBe(true)
expect(terminUeberschritten(tag(1), tag(-1))).toBe(false)
})
})
+36
View File
@@ -401,3 +401,39 @@ export function hauptuntersuchungFaellig(
const ziel = monatePlus(isoTag(z.zeit), HU_MONATE_ERSTE)
return z.genau === "monat" ? ziel.slice(0, 7) : ziel
}
/** Ist dieser Servicetermin bereits fällig oder überschritten?
*
* Der Fälligkeitstag selbst zählt mit („bereits fällig bzw. überschritten",
* Ansage des Eigentümers 07.09.2026).
*
* Entschieden wird am DATUM, nicht an den Restkilometern - und das ist keine
* Bequemlichkeit. Die Kilometerseite trägt je nach Quelle ein anderes
* Vorzeichen: die Sensoren des Fahrzeugs melden negativ, solange noch Strecke
* bleibt (am 07.09.2026 gemessen: oil_change_distance -28600 bei Fälligkeit
* 2028), und servicePrognose() klemmt ihre Restkilometer ohnehin mit
* Math.max(0, ...). Welches Vorzeichen ein überschrittener Fahrzeugwert trägt,
* lässt sich an den vorliegenden Daten nicht beobachten - geraten wird es
* nicht. Der km-Fall fällt ohnehin unter die Datumsregel: servicePrognose()
* setzt datum auf heute, sobald restKm auf 0 läuft.
*
* Genommen wird das ERSTE lesbare Datum der übergebenen Kandidaten - also das,
* das an dieser Stelle auch angezeigt wird. So kann die Markierung nicht von
* dem abweichen, was daneben steht. Wortgleich mit terminUeberschritten() im
* Panel. */
export function terminUeberschritten(
...kandidaten: (string | Date | null | undefined)[]
): boolean {
for (const kandidat of kandidaten) {
if (kandidat === null || kandidat === undefined || kandidat === "") continue
const z = alsZeitpunkt(kandidat)
if (!z) continue
const d = z.zeit
const h = new Date()
return (
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() <=
new Date(h.getFullYear(), h.getMonth(), h.getDate()).getTime()
)
}
return false
}
+15 -4
View File
@@ -18,10 +18,10 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { hauptuntersuchungFaellig, inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { hauptuntersuchungFaellig, inspektionPrognose, oelwechselPrognose, terminUeberschritten } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Wertzeile, Werteliste } from "./bausteine"
import { UeberschrittenMarke, Werteliste, Wertzeile } from "./bausteine"
import { Bild } from "./Bild"
import { GALERIE } from "./bilder"
import type { ServicebuchEintrag } from "./Service"
@@ -115,13 +115,14 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
const huFaellig = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
const oelProg = oelwechselPrognose(fahrzeug, buch)
const inspProg = inspektionPrognose(fahrzeug, buch)
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null; ueber: boolean }[] = [
{
art: "Ölwechsel",
prognose: oelProg
? `${de(oelProg.restKm)} km, voraussichtlich am ${datum(oelProg.datum)}`
: null,
haupt: fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "",
ueber: terminUeberschritten(fahrzeug.oelwechselFaelligTs, oelProg?.datum),
neben:
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
@@ -131,6 +132,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
art: "Inspektion",
prognose: inspProg ? `voraussichtlich am ${datum(inspProg.datum)}` : null,
haupt: fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "",
ueber: terminUeberschritten(fahrzeug.inspektionFaelligTs, inspProg?.datum),
neben:
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
@@ -142,6 +144,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
art: "Hauptuntersuchung",
prognose: null,
haupt: huFaellig ? datum(huFaellig) : "",
ueber: terminUeberschritten(huFaellig),
neben: null,
},
]
@@ -270,7 +273,15 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
)}
</>
}
wert={t.haupt}
wert={
t.ueber ? (
<>
{t.haupt} <UeberschrittenMarke />
</>
) : (
t.haupt
)
}
{...(t.neben ? { zusatz: t.neben } : {})}
/>
))
+18 -9
View File
@@ -14,15 +14,11 @@ import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
import { kartendienstUrl } from "../daten/tankstelle"
import { useDaten } from "../daten/DatenKontext"
import { hauptuntersuchungFaellig, inspektionPrognose, letzterInspektion, letzterOelwechsel, meldungsPrognose, oelMeldungsPrognose, oelwechselPrognose, serviceKandidaten } from "../daten/service"
import { hauptuntersuchungFaellig, inspektionPrognose, letzterInspektion, letzterOelwechsel, meldungsPrognose, oelMeldungsPrognose, oelwechselPrognose, serviceKandidaten, terminUeberschritten } from "../daten/service"
import { datum, de, eur, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import {
Blattzeile,
Leerzustand,
Zahnradknopf,
} from "./bausteine"
import { Blattzeile, Leerzustand, UeberschrittenMarke, Zahnradknopf } from "./bausteine"
import { LOESCH_HINWEIS, LOESCH_TEXT, bestaetigen } from "./bestaetigung"
import { Datumsfeld } from "./Datumsfeld"
import { terminUebernehmen } from "./kalender"
@@ -127,14 +123,25 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
const huDatum = huFaellig ? new Date(huFaellig) : null
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
// Dasselbe Datum, das gleich als Wert erscheint - siehe
// terminUeberschritten(). Die Fahrzeugmeldung geht vor, weil sie oben steht.
const oelUeber = terminUeberschritten(
oelHatMeldung ? fahrzeug.oelwechselFaelligTs : prognose?.datum,
)
const inspUeber = terminUeberschritten(
inspHatMeldung ? fahrzeug.inspektionFaelligTs : inspektionsprognose?.datum,
)
const huUeber = huGueltig && terminUeberschritten(huFaellig)
return (
<>
<Tile>
<div className="dm-termingruppe">
<div className={`dm-termingruppe${oelUeber ? " dm-termingruppe--ueberschritten" : ""}`}>
<div className="dm-tgkopf">
<SymbolOelwechsel groesse={26} />
<span>Ölwechsel</span>
{oelUeber && <UeberschrittenMarke />}
</div>
{oelHatEintrag || oelHatMeldung ? (
<>
@@ -147,10 +154,11 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
)}
</div>
<div className="dm-termingruppe">
<div className={`dm-termingruppe${inspUeber ? " dm-termingruppe--ueberschritten" : ""}`}>
<div className="dm-tgkopf">
<SymbolInspektion groesse={20} />
<span>Inspektion</span>
{inspUeber && <UeberschrittenMarke />}
</div>
{inspHatEintrag || inspHatMeldung ? (
<>
@@ -163,9 +171,10 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
)}
</div>
<div className="dm-termingruppe">
<div className={`dm-termingruppe${huUeber ? " dm-termingruppe--ueberschritten" : ""}`}>
<div className="dm-tgkopf">
<span>Hauptuntersuchung</span>
{huUeber && <UeberschrittenMarke />}
</div>
{huGueltig ? (
<div className="dm-tgwert">{datum(huFaellig)}</div>
+15 -3
View File
@@ -11,11 +11,11 @@ import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useState } from "react"
import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { naechsterService, terminUeberschritten } from "../daten/service"
import { datum, de, deOderStrich, eur, streckeText } from "../format"
import type { SeitenName } from "../navigation"
import { BILDPLAETZE, bildInfo } from "./bilder"
import { Blattzeile, Leerzustand } from "./bausteine"
import { Blattzeile, Leerzustand, UeberschrittenMarke } from "./bausteine"
// Dieselbe Ortsangabe wie in der Fahrtenliste - eine Quelle, damit "Zuletzt"
// und die Liste nicht auseinanderlaufen koennen.
import { strecke } from "./Fahrten"
@@ -81,6 +81,9 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const service = naechsterService(fahrzeug, buch, fahrten)
// Auf der Uebersicht wechselt zusaetzlich die Kennzahl die Farbe - sie ist
// hier die Aussage, nicht nur ihr Rahmen (Ansage des Eigentuemers).
const serviceUeber = !!service && terminUeberschritten(service.datum)
const letzteFahrt = fahrten
.slice()
@@ -238,7 +241,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
"differentiate controls from content" gilt auch für die
Abwesenheit von Inhalt. */}
{service ? (
<button type="button" className="dm-serviceblock" onClick={() => geheZu("service")}>
<button
type="button"
className={`dm-serviceblock${serviceUeber ? " dm-serviceblock--ueberschritten" : ""}`}
onClick={() => geheZu("service")}
>
<span className="ads-eyebrow">Nächster Service:</span>
{/* Gezeichneter Pfeil wie .go im Panel, kein Schriftzeichen: das
">" war ein 15px-Glyph der Textschrift und damit erkennbar
@@ -265,6 +272,11 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
fig
)
})()}
{serviceUeber && (
<div className="dm-servicemarke">
<UeberschrittenMarke />
</div>
)}
{/* Im Panel ist auch diese Nebenzeile ein `.label` (13px, --fg3),
nicht die kleinere Fußnote (11,5px/300), die hier stand. */}
<span className="ads-eyebrow" style={{ marginTop: "8px" }}>
+7
View File
@@ -537,3 +537,10 @@ export function Markenlogo({
/>
)
}
/** „überschritten" neben einem fälligen Servicetermin. Eigener Baustein, damit
* die drei Anzeigestellen (Service, Mein Audi, Übersicht) nicht auseinander
* laufen. Gegenstück zu UEBERSCHRITTEN_MARKE im Panel. */
export function UeberschrittenMarke() {
return <span className="dm-ueberschritten-marke">überschritten</span>
}
+44 -3
View File
@@ -12,13 +12,19 @@ import { Shell } from "../Shell"
import type { SeitenName } from "../navigation"
import { TITEL } from "../navigation"
import { SeiteFuer } from "./register"
import { beispielApi } from "../tests/beispieldaten"
import { beispielApi, beispielProfil } from "../tests/beispieldaten"
import type { Profil } from "../api/types"
import { bildschirmbreiteSetzen } from "../tests/aufbau"
const ALLE_SEITEN = Object.keys(TITEL) as SeitenName[]
function zeige(name: SeitenName, id?: string, geheZu: (name: SeitenName, id?: string) => void = () => {}) {
const api = beispielApi()
function zeige(
name: SeitenName,
id?: string,
geheZu: (name: SeitenName, id?: string) => void = () => {},
profil?: Profil,
) {
const api = profil ? beispielApi(profil) : beispielApi()
return render(
<div className="ads-root" data-theme="nacht">
<DatenAnbieter api={api}>
@@ -194,3 +200,38 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
expect(artAuswahl.value).toBe("arbeitsweg")
})
})
/* Überschrittene Servicetermine werden markiert - an allen drei Stellen, die
der Eigentümer genannt hat (07.09.2026). Die Beispieldaten tragen
erstzulassung 2023-04-01, die Hauptuntersuchung liegt damit auf 04/2026 und
ist überschritten; getestet wird also am vorhandenen Bestand, nicht an einer
eigens gebauten Fixtur. */
describe("Überschrittene Servicetermine", () => {
/* Der Regelbestand hat keinen ueberschrittenen Termin (Wartungsplan
2026-02-10 + 24 Monate). Hier deshalb ein Profil, dessen letzter Eintrag
lange zurueckliegt - die Hauptuntersuchung faellt damit in die
Vergangenheit. */
const ueberfaellig: Profil = {
...beispielProfil,
fahrzeug: { ...beispielProfil.fahrzeug, erstzulassung: "2019-04-01" },
service: { ...beispielProfil.service, buch: [] },
}
it("markiert die Gruppe auf der Service-Seite", async () => {
const { container } = zeige("service", undefined, () => {}, ueberfaellig)
await waitFor(() => expect(screen.getAllByText("überschritten").length).toBeGreaterThan(0))
expect(container.querySelector(".dm-termingruppe--ueberschritten")).not.toBeNull()
})
it("markiert die Zeile in Mein Audi", async () => {
zeige("audi", undefined, () => {}, ueberfaellig)
await waitFor(() => expect(screen.getAllByText("überschritten").length).toBeGreaterThan(0))
})
it("markiert den Serviceblock auf der Übersicht und färbt die Kennzahl", async () => {
const { container } = zeige("home", undefined, () => {}, ueberfaellig)
await waitFor(() => expect(screen.getAllByText("überschritten").length).toBeGreaterThan(0))
expect(container.querySelector(".dm-serviceblock--ueberschritten")).not.toBeNull()
})
})
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -162,7 +162,10 @@ export const beispielTankvorgaenge: Tankvorgang[] = [
]
/** Datenschicht-Ersatz für Tests: liefert Beispieldaten, schreibt ins Leere. */
export function beispielApi(): DataMetricApi {
/** Die Beispieldaten mit einem abweichenden Profil - fuer Tests, die einen
* Zustand brauchen, den der Regelbestand nicht hergibt (etwa einen bereits
* ueberschrittenen Servicetermin). Ohne Argument bleibt alles wie bisher. */
export function beispielApi(profil: Profil = beispielProfil): DataMetricApi {
const horcher = { verbindung: [] as ((z: string) => void)[] }
return {
rest: {
@@ -196,7 +199,7 @@ export function beispielApi(): DataMetricApi {
},
anzahl: 0,
},
profilLesen: async () => beispielProfil,
profilLesen: async () => profil,
fahrtenLesen: async () => beispielFahrten,
tankvorgaengeLesen: async () => beispielTankvorgaenge,
fahrzeugstatusLesen: async () => beispielStatus,