Service-Termine: CI-Icons statt Wortlaut, Anstehende-Termine neu gegliedert

Naechster-Service-Kachel (Uebersicht): das "bis zum Oelwechsel"/
"bis zur Inspektion" vor der Kennzahl entfaellt zugunsten der echten
Audi-CI-Icons oil-change/inspection (vom Nutzer als SVG geliefert,
verbatim uebernommen wie die uebrigen CI-Icons). Hauptuntersuchung hat
kein eigenes Icon (kein Restkilometer-Ziel) und zeigt stattdessen ihr
Datum in derselben 40px-Groesse wie die km-Zahlen, mit dem Wort
"Hauptuntersuchung" darunter statt "bis zur Hauptuntersuchung" - vorher
war das Datumsfeld kleiner (30px), um Umbruch zu vermeiden; am
laufenden Panel nachgemessen, dass 40px keinen Umbruch verursacht.
"vsl." wird ausgeschrieben zu "voraussichtlich am". bisText()/ART_BIS
(Panel) und bisText()/BIS_TEXT (companion-app) wurden durch die
Aenderung ueberfluessig und als Orphans entfernt.

Anstehende-Termine-Box (Service-Seite): aus der dt/dd-Liste werden drei
sichtbar getrennte Bloecke (Kopfzeile mit Icon + Name + optionalem
Werkstatt-Chip, optionale Herstellervorgabe-Zeile, grosse Kennzahl,
optionale Prognose-Fusszeile) - Apple HIG (Hierarchie ueber
Position/Groesse, Inhalt nicht mit Nebensaechlichem ueberladen) plus
Audi-CI (Flaeche mit Haarlinien statt Karten). Durchlief drei
Mockup-Runden als Artefakt vor der Umsetzung, zwei davon vom Nutzer
zurueckgewiesen ("Major Information is missing" bzw. Kommentare zu
Icon-Groesse/Herstellervorgabe/Ausrichtung).

Herstellervorgabe-Logik korrigiert: eine Fahrzeugmeldung stammt immer
aus dem werkseigenen Wartungsprogramm des Bordcomputers, unabhaengig
vom in "Einrichten" gewaehlten App-Intervall - zaehlt also immer als
Herstellervorgabe, nicht nur wenn der App-Modus zufaellig "hersteller"
ist. Die Prognose-Fusszeile zeigt die Restkilometerzahl nur noch beim
eigenen (kuerzeren) Intervall, bei Herstellervorgabe nur noch das
Datum. "Eigenes" in der Ölwechsel-Intervall-Auswahl umbenannt zu
"Individuell".

companion-app-Portierung ist eine echte Neustrukturierung: die
bisherige "Naechster Oelwechsel"-Kachel mischte Oelwechsel-,
Inspektions- und Hauptuntersuchungs-Zeilen in einer gemeinsamen
Werteliste, jetzt "Anstehende Termine" mit denselben drei Bloecken wie
im Panel. Neuer Export letzterInspektion() in service.ts. Zwei
dokumentierte, vorbestehende Luecken bleiben bestehen statt neu
kaschiert zu werden: kein Werkstatt-Chip (companion-app hat keine
"Termin vereinbaren"-Funktion), Herstellervorgabe kommt aus
fahrzeug.oel.modus statt einem intervalle()-Aequivalent (das es dort
nicht gibt).

npm run typecheck sauber, npm run test 99/99, npm run build erfolgreich.
Panel deployed als Version 1787013003, synchron in installationspaket/,
live im Docker-Testcontainer per DOM-Abfrage geprueft.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-19 00:03:16 +02:00
co-authored by Claude Sonnet 5
parent 2848b5286f
commit 23b6defa68
13 changed files with 600 additions and 179 deletions
+1 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { bisText, inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
import { inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
@@ -142,10 +142,4 @@ describe("naechsterService", () => {
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
expect(naechsterService(fahrzeug(46000), [], jetzt)).toBeNull()
})
it("bisText nennt den richtigen Artikel je Art", () => {
expect(bisText("Ölwechsel")).toBe("bis zum Ölwechsel")
expect(bisText("Inspektion")).toBe("bis zur Inspektion")
expect(bisText("Hauptuntersuchung")).toBe("bis zur Hauptuntersuchung")
})
})
+5 -13
View File
@@ -51,6 +51,11 @@ export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | n
return letzterEintrag(buch, /öl|oel/i)
}
/** Der jüngste Servicebucheintrag, dessen Art auf eine Inspektion hindeutet. */
export function letzterInspektion(buch: readonly Buchhaltung[]): Buchhaltung | null {
return letzterEintrag(buch, /inspektion/i)
}
/** Gemeinsamer Kern für Ölwechsel- und Inspektionsprognose - beide rechnen
identisch, nur mit anderem Intervall und anderer Suche im Servicebuch. */
function servicePrognose(
@@ -134,19 +139,6 @@ export interface NaechsterService {
datum: string
}
/* "der Ölwechsel", aber "die Inspektion"/"die Hauptuntersuchung" - ohne diese
Zuordnung stünde auf der Übersicht "bis zum Inspektion". Gleiche Tabelle wie
ART_BIS im Panel (audi-dashboard-app.js). */
const BIS_TEXT: Record<Serviceart, string> = {
"Ölwechsel": "bis zum Ölwechsel",
"Inspektion": "bis zur Inspektion",
"Hauptuntersuchung": "bis zur Hauptuntersuchung",
}
export function bisText(art: Serviceart): string {
return BIS_TEXT[art]
}
/**
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
* Gegenstück zu `naechsterTermin()` im Panel.
+100 -46
View File
@@ -13,9 +13,10 @@ import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { inspektionPrognose, letzterInspektion, letzterOelwechsel, oelwechselPrognose } from "../daten/service"
import { datum, de, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { kalenderDateiLaden } from "./kalender"
@@ -44,53 +45,106 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
const prognose = oelwechselPrognose(fahrzeug, buch)
const inspektionsprognose = inspektionPrognose(fahrzeug, buch)
// Ölwechsel und Inspektion je einzeln, statt einer gemeinsamen Wertzeilen-
// Liste unter "Nächster Ölwechsel" (die Inspektion/Hauptuntersuchung dort
// fachfremd mitschleppte) - deckungsgleich mit vService() im Panel: drei
// sichtbar getrennte Blöcke, jeder mit eigenem Icon, optionaler
// Herstellervorgabe-Zeile und optionaler Prognose-Fußzeile.
const oelHatEintrag = letzterOelwechsel(buch) != null
const oelHatMeldung = fahrzeug.oelwechselFaelligTs != null || fahrzeug.oelwechselFaelligKm != null
const oelWert = oelHatMeldung
? [
fahrzeug.oelwechselFaelligKm != null ? `${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km` : null,
fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : null,
]
.filter(Boolean)
.join(" / ")
: prognose
? `${de(prognose.restKm)} km · ${datum(prognose.datum)}`
: 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
// (kürzeren) Intervall ist sie eine echte Zusatzinformation.
const oelFussnote = oelHatMeldung && prognose
? fahrzeug.oel.modus === "hersteller"
? `voraussichtlich am ${datum(prognose.datum)}`
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
: null
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
// einem kürzeren eigenen Intervall (fahrzeug.oel.modus) - die Inspektion hat
// kein einstellbares Intervall, rechnet also immer mit der Herstellervorgabe
// (siehe inspektionPrognose()'s eigener Kommentar dazu). Eine Fahrzeugmeldung
// stammt aber immer aus dem werkseigenen Wartungsprogramm des Bordcomputers -
// der kennt das eigene App-Intervall gar nicht -, zählt also unabhängig vom
// gewählten Modus als Herstellervorgabe.
const oelHerstellervorgabe = oelHatMeldung || fahrzeug.oel.modus === "hersteller"
const inspHatEintrag = letzterInspektion(buch) != null
const inspHatMeldung = fahrzeug.inspektionFaelligTs != null || fahrzeug.inspektionFaelligKm != null
const inspWert = inspHatMeldung
? [
fahrzeug.inspektionFaelligKm != null ? `${de(Math.abs(fahrzeug.inspektionFaelligKm))} km` : null,
fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : null,
]
.filter(Boolean)
.join(" / ")
: inspektionsprognose
? datum(inspektionsprognose.datum)
: null
const inspFussnote = inspektionsprognose ? `voraussichtlich am ${datum(inspektionsprognose.datum)}` : null
const huDatum = fahrzeug.hu ? new Date(fahrzeug.hu) : null
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
return (
<>
<Tile>
<span className="ads-eyebrow">Nächster Ölwechsel</span>
<Werteliste
kinder={
<span className="ads-eyebrow">Anstehende Termine</span>
<div className="dm-termingruppe">
<div className="dm-tgkopf">
<SymbolOelwechsel groesse={26} />
<span>Ölwechsel</span>
</div>
{oelHatEintrag ? (
<>
<Wertzeile
label="Vom Fahrzeug gemeldet"
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
zusatz={
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Eigene Prognose"
wert={prognose ? datum(prognose.datum) : "—"}
zusatz={
prognose
? `${de(prognose.restKm)} km offen · ${de(prognose.kmProTag, 1)} km/Tag`
: "Noch kein Ölwechsel im Servicebuch"
}
/>
<Wertzeile
label="Nächste Inspektion"
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
zusatz={
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: undefined
}
/>
{inspektionsprognose && (
<Wertzeile
label="Eigene Prognose (Inspektion)"
wert={datum(inspektionsprognose.datum)}
/>
)}
<Wertzeile
label="Hauptuntersuchung"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
{oelHerstellervorgabe && <div className="dm-tgvorgabe">Herstellervorgabe</div>}
<div className="dm-tgwert">{oelWert}</div>
{oelFussnote && <div className="dm-tgprognose">{oelFussnote}</div>}
</>
}
/>
) : (
<div className="dm-tgwert dm-tgwert--leer">kein Eintrag im Servicebuch</div>
)}
</div>
<div className="dm-termingruppe">
<div className="dm-tgkopf">
<SymbolInspektion groesse={20} />
<span>Inspektion</span>
</div>
{inspHatEintrag ? (
<>
<div className="dm-tgvorgabe">Herstellervorgabe</div>
<div className="dm-tgwert">{inspWert}</div>
{inspFussnote && <div className="dm-tgprognose">{inspFussnote}</div>}
</>
) : (
<div className="dm-tgwert dm-tgwert--leer">kein Eintrag im Servicebuch</div>
)}
</div>
<div className="dm-termingruppe">
<div className="dm-tgkopf">
<span>Hauptuntersuchung</span>
</div>
{huGueltig ? (
<div className="dm-tgwert">{datum(fahrzeug.hu)}</div>
) : (
<div className="dm-tgwert dm-tgwert--leer">kein Eintrag im Servicebuch</div>
)}
</div>
{prognose && (
<div style={{ marginTop: 14 }}>
<ActionButton
@@ -103,9 +157,9 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
</div>
)}
<p className="dm-fussnote">
Die eigene Prognose rechnet ab dem letzten Ölwechsel im Servicebuch neu und nutzt die
seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des Fahrzeugs nicht,
sondern ergänzt sie.
Die eigene Prognose rechnet ab dem letzten Eintrag der jeweiligen Art im Servicebuch neu
und nutzt die seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des
Fahrzeugs nicht, sondern ergänzt sie.
</p>
</Tile>
+30 -9
View File
@@ -11,14 +11,24 @@ import { useState } from "react"
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { bisText, naechsterService } from "../daten/service"
import { naechsterService } from "../daten/service"
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel } from "./bausteine"
import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import { Typenschild, schildErkennen } from "./Typenschild"
// "bis zum Ölwechsel"/"bis zur Inspektion" entfaellt zugunsten des CI-Icons
// links der Kennzahl - deckungsgleich mit ART_ICON im Panel (vHome()). Die
// Hauptuntersuchung hat kein Restkilometer-Ziel und zeigt stattdessen den
// Art-Namen selbst unter dem (gleich großen) Datum.
const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
"Ölwechsel": SymbolOelwechsel,
"Inspektion": SymbolInspektion,
}
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
useDaten()
@@ -143,15 +153,26 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
</span>
{service ? (
<>
<Fig
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
{...(service.restKm !== null ? { unit: "km" } : {})}
size={service.restKm !== null ? 40 : 30}
/>
{(() => {
const Symbol = SERVICE_SYMBOL[service.art]
const fig = (
<Fig
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
{...(service.restKm !== null ? { unit: "km" } : {})}
size={40}
/>
)
return Symbol ? (
<div className="dm-servicefig">
<Symbol groesse={31} />
{fig}
</div>
) : (
fig
)
})()}
<span className="dm-fussnote">
{service.restKm !== null
? `${bisText(service.art)} · vsl. ${datum(service.datum)}`
: bisText(service.art)}
{service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art}
</span>
</>
) : (
+62
View File
@@ -214,6 +214,17 @@
line-height: 1;
color: var(--fg3);
}
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */
.dm-servicefig {
display: flex;
align-items: center;
gap: 8px;
}
.dm-servicefig svg {
color: var(--fg2);
flex: none;
}
.dm-servicezeile {
display: flex;
align-items: baseline;
@@ -229,6 +240,57 @@
color: var(--fg2);
}
/* Service-Seite, "Anstehende Termine": drei sichtbar getrennte Bloecke statt
einer Wertzeilen-Liste - deckungsgleich mit .termingruppe im Panel
(audi-dashboard.css, vService()). Trennung ueber Abstand und eine Haarlinie
zwischen den Bloecken, nicht ueber verschachtelte Kacheln (Audi-CI). */
.dm-termingruppe {
padding: 18px 0;
border-top: 1px solid var(--line);
}
.dm-termingruppe:first-of-type {
padding-top: 2px;
border-top: 0;
}
.dm-tgkopf {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.dm-tgkopf svg {
color: var(--fg2);
flex: none;
}
.dm-tgkopf span {
font-size: 16.5px;
color: var(--fg);
font-weight: 400;
}
.dm-tgvorgabe {
font-size: 11.5px;
color: var(--fg2);
margin-top: 8px;
}
.dm-tgwert {
font-weight: 300;
font-size: 22px;
color: var(--fg);
letter-spacing: -0.005em;
margin-top: 4px;
}
.dm-tgwert--leer {
font-weight: 400;
font-size: 15px;
color: var(--fg3);
margin-top: 8px;
}
.dm-tgprognose {
font-size: 12px;
color: var(--fg2);
margin-top: 6px;
}
.dm-tanktext {
display: flex;
justify-content: space-between;
+42
View File
@@ -147,6 +147,48 @@ export function SymbolMehr({ groesse }: SymbolProps) {
)
}
/**
* oil-change-s/inspection-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG
* geliefert, verbatim übernommen — 24er-Grid, wie im Panel als CI.oilChangeS/
* CI.inspectionS). Anders als die generischen Rahmen()-Symbole oben sind das
* gefüllte Flächen-Icons, deshalb kein stroke-Wrapper.
*/
function RahmenFlaeche({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) {
return (
<svg width={groesse} height={groesse} viewBox="0 0 24 24" fill="none" aria-hidden="true">
{kinder}
</svg>
)
}
export function SymbolOelwechsel({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="M9 8H7.5V7h4v1H10v2h2.77l.94 1.41 7.93-2.48 2.21 2.22-.7.7-1.79-1.78-1.03.32L15.25 17H4v-6.8L2.8 9H0V8h3.2l2 2H9zm-4 3v5h9.75l3.92-5.1-5.38 1.69L12.23 11z"
/>
}
/>
)
}
export function SymbolInspektion({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="M3.3 0H5v3.3l.7.7h2.6l.7-.7V0h1.7L13 2.3v4.4l-3 3V21c0 .69-.3 1.42-.76 1.98A2.57 2.57 0 0 1 7.3 24h-.6c-.79 0-1.47-.42-1.93-.98A3.22 3.22 0 0 1 4 21V9.7l-3-3V2.3zm.4 1L2 2.7v3.6l3 3V21c0 .47.2.98.53 1.38.34.4.76.62 1.17.62h.6c.4 0 .82-.24 1.16-.65.34-.42.54-.94.54-1.35V9.3l3-3V2.7L10.3 1H10v2.7L8.7 5H5.3L4 3.7V1zm12.44-1h3.72L21 3.42V6.7l-1 1V14h1v7.5a2.5 2.5 0 0 1-2.5 2.5h-1a2.5 2.5 0 0 1-2.5-2.5V14h1V7.7l-1-1V3.43zM16 15v6.5c0 .82.68 1.5 1.5 1.5h1c.82 0 1.5-.68 1.5-1.5V15zm3-1V7.3l1-1V3.57L19.14 1h-2.28L16 3.58V6.3l1 1V14z"
/>
}
/>
)
}
export function SymbolWarnung({ groesse }: SymbolProps) {
return (
<Rahmen