Vollständiger Panel-companion-app-Paritätsaudit (11 Kapitel)

Systematischer Bildvergleich der companion-app gegen das HA-Panel (companion-app/AGENTS.md
Kapitel 1-11): Farbtoken/Radien aus dem falschen Stylesheet gelesen (iOS-Overlay statt Basis-CSS,
betraf fast jede Kachel/Farbe/Radius app-weit), vier app-weite @audi-dash/ui-Bugs (Switch/Seg/Feld
rot statt neutral bzw. falsche Feldbreite), Reifen-Seite strukturell neu gebaut (beide Radsätze
gleichzeitig statt Umschalter, editierbare Felder, Anzugsmoment-/km-Korrektur), Standort-Feature
komplett neu (fehlte bisher ganz), sowie diverse Struktur-/Typografie-/Datenlücken in
MeinAudi/Service/Versicherung/Sicherheit/Fahrten/Tanken/Statistik/Batterie/Einstellungen.

Manifest auf 2026.8.30.2 angehoben, OTA-Bündel neu gebaut und in audi_ha_test verifiziert
(sauberer Neustart, keine Tracebacks).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-30 12:21:58 +02:00
co-authored by Claude Sonnet 5
parent 4fa5d4bc2d
commit e1ebe6d694
43 changed files with 3256 additions and 451 deletions
+22
View File
@@ -43,6 +43,28 @@ ARCHIV="$AUSGABE/DataMetric360.xcarchive"
cd "$APP"
# Ohne diese Pruefung baut/signiert das Skript zuverlaessig - aber nur den
# Stand, der gerade lokal ausgecheckt ist. Ein vergessenes `git pull` waere
# damit ein stiller Veraltungsfehler wie schon mehrfach an anderer Stelle in
# diesem Projekt (Cache-Busting, OTA-Buendel-Abgleich, VERSIONIERUNG.md) - hier
# stattdessen ein lauter Abbruch statt eines optisch/inhaltlich alten Builds.
echo "==> Git-Stand pruefen"
if [ -n "$(git status --porcelain)" ]; then
echo "FEHLER: Uncommittete Aenderungen im Arbeitsverzeichnis." >&2
git status --short >&2
echo "Erst committen oder stashen, dann erneut versuchen." >&2
exit 1
fi
git fetch origin
LOKAL="$(git rev-parse HEAD)"
FERN="$(git rev-parse origin/main)"
if [ "$LOKAL" != "$FERN" ]; then
echo "FEHLER: Lokaler Stand ($LOKAL) weicht von origin/main ($FERN) ab." >&2
echo "Erst 'git pull' ausfuehren - sonst signiert dieses Skript einen veralteten Stand." >&2
exit 1
fi
echo " HEAD == origin/main ($LOKAL), aktuell."
echo "==> Webbuendel bauen"
npm run build
+1 -1
View File
@@ -65,7 +65,7 @@ describe("Shell", () => {
expect(bereichVon("trip")).toBe("trips")
})
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
it("erreicht die Einstellungen über die Ringe, nicht über die Tab-Leiste", () => {
const geheZu = rahmen("home")
fireEvent.click(screen.getByLabelText("Einstellungen"))
expect(geheZu).toHaveBeenCalledWith("einst")
+9 -1
View File
@@ -64,6 +64,14 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
</div>
{/* `kopf` wird nur in der schmalen Spalte gerendert (breit hat seinen
eigenen Seitenleisten-Kopf, siehe unten) - dort sind die Audi-Ringe
oben rechts der einzige Zugang zu Einstellungen, das Zahnrad
entfällt, "damit oben rechts nur ein Element sitzt" (Design-Audit
2026-08-13, siehe .rings/.profilbtn in audi-dashboard-ios.css).
Companion-app zeigte hier bisher immer ein Zahnrad, unabhängig von
der Bildschirmbreite - echte Abweichung, nicht nur Kosmetik: es gab
keinen Rings-Zugang in Telefonbreite. */}
<button
type="button"
className="dm-kopf__aktion"
@@ -71,7 +79,7 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad groesse={22} />
<VierRinge hoehe={17} />
</button>
</header>
)
+14
View File
@@ -210,6 +210,20 @@ export class DataMetricApi {
);
}
/** Korrigiert nur den Kilometerzähler von `satz` - bewusst kein
profilSpeichern() (siehe reifenzaehler.py): ein eigener Dienst, der
referenz_odo_km unangetastet lässt, damit die nächste automatische
Fortschreibung ab dem neuen Stand weiterzählt statt ihn zu
überschreiben. Deckungsgleich mit data-kmspeichern im Panel. */
reifenKmSetzen(satz: "sommer" | "winter", km: number): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_km_setzen",
{ satz, km },
"Kilometerstand korrigieren",
);
}
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
+8
View File
@@ -47,6 +47,13 @@ export interface DatenWert {
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe appVersion.ts und VERSIONIERUNG.md. */
versionsstand: Versionsstand
/** Die vom Backend gemeldete Integrationsversion (`APP_VERSION`,
`sensor.audi_dashboard_app_version`'s `app`-Feld), oder null vor der
ersten Antwort. Panel-Gegenstück: die "Version"-Kachel in vEinst()
("Installiert: ..."), die hier bislang fehlte, weil nur das
Vergleichsergebnis (`versionsstand`), nie der Rohwert selbst,
weitergereicht wurde. */
serverVersion: string | null
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
Verbindung. */
@@ -226,6 +233,7 @@ export function DatenAnbieter({
warteschlange,
statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
serverVersion,
otaBuendel,
integrationUpdate,
einstellungen,
+2
View File
@@ -60,6 +60,7 @@ export interface Fahrzeug {
odo: number
odoBekannt: boolean
tankPct: number
tankBekannt: boolean
reichweite: number | null
batteriespannung: number | null
gesichert: boolean | null
@@ -164,6 +165,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
odo: status.km ?? 0,
odoBekannt: status.km !== null && status.km !== undefined,
tankPct: status.tankprozent ?? 0,
tankBekannt: status.tankprozent !== null && status.tankprozent !== undefined,
reichweite: status.reichweite_km ?? null,
batteriespannung: status.batteriespannung ?? null,
gesichert: status.gesichert ?? null,
+1
View File
@@ -21,6 +21,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
odo,
odoBekannt: true,
tankPct: 0,
tankBekannt: true,
reichweite: null,
batteriespannung: null,
gesichert: null,
+3
View File
@@ -35,6 +35,7 @@ export type SeitenName =
// Sonstige
| "sicherheit"
| "tuerenklappen"
| "standort"
| "live"
| "einst"
@@ -68,6 +69,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
einst: "home",
sicherheit: "home",
tuerenklappen: "sicherheit",
standort: "home",
live: "home",
}
@@ -96,6 +98,7 @@ export const TITEL: Record<SeitenName, string> = {
fill: "Tankvorgang",
sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen",
standort: "Standort",
live: "Fahrt läuft",
einst: "Einstellungen",
}
+45 -2
View File
@@ -57,6 +57,48 @@ const LADEKURVE: [number, number][] = [
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
]
/** Deckungsgleich mit bvHexZuRgb()/bvMischen()/bvFarbe()/bvFarbstufen() im
Panel: der einzelne Messpunkt ist nach Ruhespannung eingefärbt (rot →
gelb → grün), nicht - wie hier bis 2026-08-30 - durchgehend `var(--red)`.
Rot als durchgehende Diagrammfarbe verstößt gegen die eigene Projektregel
"Rot ist Akzent, nie Fläche" (siehe die Statistik-/TabBar-Funde weiter
oben in dieser Datei) und war hier schlicht nie an die Panel-Logik
angeglichen worden. */
function hexZuRgb(hex: string): [number, number, number] {
const h = hex.trim().replace("#", "")
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return [Number.isFinite(r) ? r : 0, Number.isFinite(g) ? g : 0, Number.isFinite(b) ? b : 0]
}
function mischen(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
]
}
function farbstufenLesen() {
const stil = typeof getComputedStyle === "function" ? getComputedStyle(document.documentElement) : null
const wert = (name: string, ersatz: string) => {
const v = stil?.getPropertyValue(name).trim()
return v ? v : ersatz
}
return {
bad: hexZuRgb(wert("--bad", "#FF453A")),
warn: hexZuRgb(wert("--warn", "#FFD60A")),
ok: hexZuRgb(wert("--ok", "#30D158")),
}
}
function farbeFuerSpannung(v: number, stufen: ReturnType<typeof farbstufenLesen>): string {
let rgb: [number, number, number]
if (v <= 11.6) rgb = stufen.bad
else if (v <= 12.2) rgb = mischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6))
else if (v <= 12.8) rgb = mischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2))
else rgb = stufen.ok
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
}
function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null
const t = LADEKURVE
@@ -230,6 +272,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
const stufen = farbstufenLesen()
return (
<>
@@ -241,7 +284,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen}
>
<path d={linie} fill="none" stroke="var(--red)" strokeWidth="1.5" strokeLinejoin="round" />
<path d={linie} fill="none" stroke="var(--fg)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
{werte.map((t, i) =>
t.min == null ? null : (
<circle
@@ -249,7 +292,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
cx={x(i)}
cy={y(t.min)}
r={gewaehlt === i ? 4.5 : 2.4}
fill="var(--red)"
fill={farbeFuerSpannung(t.min, stufen)}
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
/>
),
+117 -24
View File
@@ -4,7 +4,7 @@
* Datenausgabe und Zugang.
*/
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
@@ -18,7 +18,7 @@ import { datumZeit, de, isoTag } from "../format"
import { useTheme } from "../theme"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import { BILDPLAETZE, markenlogoUrl } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
@@ -41,6 +41,7 @@ export function Einstellungen({
rohprofil,
otaBuendel,
integrationUpdate,
serverVersion,
verbindung,
api,
profilSpeichern,
@@ -59,6 +60,9 @@ export function Einstellungen({
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null)
const [backupImportLaeuft, setzeBackupImportLaeuft] = useState(false)
const [backupImportFehler, setzeBackupImportFehler] = useState<string | null>(null)
const backupDateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
@@ -189,6 +193,39 @@ export function Einstellungen({
await neuLaden()
}
// Gegenstück zu profilExportieren() - dieselbe Paketform ({profil, fahrten,
// tankvorgaenge}) über den bestehenden backup_wiederherstellen-Dienst,
// deckungsgleich mit dem data-backup="import"-Handler im Panel. Fehlte hier
// bisher komplett - "Auf dieses Gerät laden" hatte kein Gegenstück, ein
// exportiertes Backup ließ sich nie wieder einspielen.
const backupImportieren = async (datei: File) => {
setzeBackupImportFehler(null)
setzeBackupImportLaeuft(true)
try {
const text = await datei.text()
const paket = JSON.parse(text) as {
profil?: unknown
fahrten?: unknown
tankvorgaenge?: unknown
entitaeten?: unknown
}
if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt).")
await api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_wiederherstellen", {
profil_json: JSON.stringify(paket.profil),
fahrten_json: JSON.stringify(paket.fahrten ?? []),
tankvorgaenge_json: JSON.stringify(paket.tankvorgaenge ?? []),
...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}),
})
await neuLaden()
} catch (ursache) {
setzeBackupImportFehler(
ursache instanceof Error ? ursache.message : "Die Datei ließ sich nicht lesen.",
)
} finally {
setzeBackupImportLaeuft(false)
}
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
@@ -240,8 +277,12 @@ export function Einstellungen({
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrten</span>
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten">
{/* Panel-Wortlaut: "Fahrten zusammenfassen" / "Pause bis" - war hier
als "Fahrten"/"Pause zwischen zwei Fahrten" eigenständig
formuliert, brach dadurch in der 16px-Label-Spalte (Parität-Audit
2026-08-30, Feld-Label-Korrektur) Wort für Wort um. */}
<span className="ads-eyebrow">Fahrten zusammenfassen</span>
<Feld label="Pause bis" unit="Minuten">
<input
className="dm-eingabe"
inputMode="numeric"
@@ -327,7 +368,22 @@ export function Einstellungen({
<Tile>
<span className="ads-eyebrow">Tankrabatt</span>
<Feld label={`${werte.markenname}-Rabatt aktiv`}>
<Feld
label={
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<img
src={markenlogoUrl()}
alt={werte.markenname}
style={{ height: 14, width: "auto" }}
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
{werte.markenname}-Rabatt aktiv
</span>
}
last={!fahrzeug.smartdeal.aktiv}
>
<Switch
checked={fahrzeug.smartdeal.aktiv}
onChange={(an) =>
@@ -341,25 +397,31 @@ export function Einstellungen({
aria-label="Tankrabatt aktiv"
/>
</Feld>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={fahrzeug.smartdeal.laeuftAb ?? ""}
onChange={(e) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
},
})
}
/>
</Feld>
<p className="dm-fussnote">
Das Datum gilt einschließlich am Stichtag selbst zählt der Rabatt noch, erst am Folgetag
schaltet er ab.
</p>
{fahrzeug.smartdeal.aktiv ? (
<>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={fahrzeug.smartdeal.laeuftAb ?? ""}
onChange={(e) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
},
})
}
/>
</Feld>
<p className="dm-fussnote">
Das Datum gilt einschließlich am Stichtag selbst zählt der Rabatt noch, erst am
Folgetag schaltet er ab.
</p>
</>
) : (
<p className="dm-fussnote">Ersparnis und Bildmarke werden ausgeblendet.</p>
)}
</Tile>
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
@@ -430,7 +492,25 @@ export function Einstellungen({
Jetzt sichern
</ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
<ActionButton
onClick={() => backupDateiwahl.current?.click()}
disabled={backupImportLaeuft}
>
{backupImportLaeuft ? "Lade …" : "Von diesem Gerät einspielen"}
</ActionButton>
</div>
<input
ref={backupDateiwahl}
type="file"
accept="application/json"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void backupImportieren(datei)
e.target.value = ""
}}
/>
{backupImportFehler && <p className="dm-fehler">{backupImportFehler}</p>}
</Tile>
<Tile>
@@ -485,6 +565,19 @@ export function Einstellungen({
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Version</span>
<Werteliste
kinder={
<Wertzeile
label="Installiert"
wert={serverVersion ?? "unbekannt"}
zusatz="Home-Assistant-Integration"
/>
}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Integration-Update</span>
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
+1 -1
View File
@@ -60,7 +60,7 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
</button>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
<div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
+13 -1
View File
@@ -16,6 +16,18 @@ import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from ".
import type { FahrtFelderEingabe } from "./FahrtFelder"
import { Zeilenmenue } from "./Zeilenmenue"
/** Vorlage: strecke() im Panel - "Start → Ziel", nur eine Seite, oder
"Fahrt ohne Ortsangabe". Trug bisher in der Liste hier gar nichts bei
(nur auf der Einzelfahrt-Seite sichtbar) - im Panel ist das die
führende Information jeder Fahrtzeile. */
function strecke(fahrt: { start_address?: string | null; end_address?: string | null }): string | null {
const a = fahrt.start_address, b = fahrt.end_address
if (!a && !b) return "Fahrt ohne Ortsangabe"
if (!b) return a ?? null
if (!a) return b
return `${a}${b}`
}
export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrten, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
@@ -80,7 +92,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
{deOderStrich(fahrt.distance_km, 1)} km
</span>
<span className="dm-listenzeile__unter">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{strecke(fahrt)} · {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{dauer(fahrt.duration_s)}
</span>
</span>
+23 -14
View File
@@ -11,15 +11,15 @@
* Motorhaube und dem Kofferraum einzeln.
*/
import { StatusRow, Tile } from "@audi-dash/ui"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datumZeit } from "../format"
import type { SeitenName } from "../navigation"
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
import { Bild } from "./Bild"
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { fahrzeug, statusStand } = useDaten()
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const punkte = fahrzeug.sicherheitscheck
@@ -36,20 +36,29 @@ export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void
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"))
const gesichert = fahrzeug.gesichert
const sammelText =
gesichert === null
? "Zustand nicht vollständig bekannt"
: gesichert
? "Fahrzeug ist sicher abgestellt"
: "Bitte prüfen"
return (
<>
<StatusRow
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
title={
fahrzeug.gesichert === true
? "Fahrzeug ist sicher abgestellt"
: fahrzeug.gesichert === false
? "Bitte prüfen"
: "Zustand nicht vollständig bekannt"
}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
/>
{/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war
hier komplett unportiert (Parität-Audit 2026-08-30). */}
<div className="dm-szene">
<Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
</div>
{/* Deckungsgleich mit der obersten Kachel in vSicherheit(): dasselbe
Kreis-Häkchen-Symbol wie die vier Zeilen unten (nicht der generische
StatusRow-Punkt), keine Zeitangabe - das Panel zeigt dort keine. */}
<Tile className="dm-sicherheitssammel">
<StatusKreis ok={gesichert} />
<span>{sammelText}</span>
</Tile>
<Tile>
<div className="dm-sicherheitsliste">
+12 -4
View File
@@ -53,20 +53,28 @@ export function Karte({
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
const punkte: [number, number][] = [[start.lat, start.lon]]
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" })
// Deckungsgleich mit initMap() im Panel: Start schwarz mit weißem Ring,
// Ziel rot - erkennbar unterschiedlich, nicht beide in derselben Farbe.
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 })
.bindTooltip("Start")
.addTo(karte)
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true })
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", weight: 2, fillColor: "#F50537", fillOpacity: 1 })
.bindTooltip("Ziel")
.addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen.
const linie = route && route.length >= 2 ? route : punkte
L.polyline(linie, { color: "#F50537", weight: 2, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
if (route && route.length >= 2) punkte.push(...route)
L.polyline(linie, { color: "#F50537", weight: 2.5, opacity: 0.9, dashArray: route && route.length >= 2 ? undefined : "4 6" }).addTo(karte)
if (route && route.length >= 2) {
// Kleine Zwischenpunkte entlang der echten Strecke, wie im Panel.
route.slice(1, -1).forEach((punkt) => {
L.circleMarker(punkt, { radius: 2.5, color: "#F50537", weight: 0, fillColor: "#F50537", fillOpacity: 0.85 }).addTo(karte!)
})
punkte.push(...route)
}
}
if (punkte.length > 1) {
+181 -44
View File
@@ -1,72 +1,209 @@
/**
* „Mein Audi" — Sammelseite mit Fotogalerie und Wegen zu den Unterseiten.
* Vorlage: `vAudi()` im alten Panel.
* „Mein Audi" — Sammelseite mit Fahrzeugbild, Kennzeichen und den Wegen zu
* den Unterseiten, jede mit einer echten Datenvorschau. Vorlage: `vAudi()`
* im alten Panel.
*
* War bis 2026-08-30 eine reine Foto-Rasteransicht plus fünf generische
* "Los"-Kacheln mit Platzhaltertext ("Termine und Wartungsplan") statt
* echter Werte — eine ganz andere, ärmere Seite als das Panel-Vorbild
* (Parität-Audit). Jetzt strukturell nachgebaut: ein einzelnes, antippbares
* Wechselbild (wie `carImg("galerie")`) statt eines Rasters aller Plätze
* (das Raster gehört zu Einstellungen → Bild der Übersicht, nicht hierher),
* eine randlose Marke/Kennzeichen-Zeile, und vier Kacheln mit echter
* Werteliste + Chevron statt einer Zeile Fließtext.
*/
import { useState } from "react"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { de, deOderStrich } from "../format"
import { naechsterService } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { NaviKachel } from "./bausteine"
import { Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
import type { ServicebuchEintrag } from "./Service"
import { Typenschild, schildErkennen } from "./Typenschild"
interface AusstattungGruppe {
gruppe?: string
sonder?: string[]
serie?: string[]
}
export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen } = useDaten()
const { fahrzeug, einstellungen, fahrten } = useDaten()
const [bildIndex, setzeBildIndex] = useState(0)
if (!fahrzeug || !einstellungen) return null
const aktiverSatz =
fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const platz = BILDPLAETZE[bildIndex % BILDPLAETZE.length]!
const ausstattung = Array.isArray(fahrzeug.ausstattung)
? (fahrzeug.ausstattung as AusstattungGruppe[])
: []
const positionen = ausstattung.reduce((n, g) => n + (g.sonder?.length ?? 0), 0)
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
// Vereinfacht gegenüber vAudi() im Panel, das hier die volle
// Drei-Termine-Liste (Ölwechsel/Inspektion/Hauptuntersuchung) zeigt: nur
// der zeitlich nächste Termin (dieselbe Funktion, die schon die
// Übersicht-Kachel speist). Die volle Aufschlüsselung bleibt der
// Service-Seite selbst vorbehalten — bewusste Vereinfachung, nicht
// vergessen, siehe AGENTS.md.
const service = naechsterService(fahrzeug, buch, fahrten)
const schild = schildErkennen(einstellungen.fahrzeugtitel)
const ausfuehrung = einstellungen.ausfuehrung.trim()
const zusatz = [
schild?.zusatz,
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
? ausfuehrung
: "",
]
.filter(Boolean)
.join(" ")
.trim()
return (
<>
<Tile>
<span className="ads-eyebrow">Fotogalerie</span>
<div className="dm-galerie">
{BILDPLAETZE.map((platz) => (
<Bild
key={platz.datei}
datei={platz.datei}
beschreibung={platz.beschreibung}
name={platz.name}
groesse="mini"
/>
))}
<button
type="button"
className="dm-galerie-wechsel"
onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
aria-label="Nächstes Bild"
>
<Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.name} />
</button>
<div className="dm-galerie-punkte" aria-hidden="true">
{BILDPLAETZE.map((b, i) => (
<span key={b.datei} className={i === bildIndex ? "an" : ""} />
))}
</div>
<Tile variant="flat" className="dm-szene__text">
<div className="dm-szene__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
<div className="dm-szene__zusatz">
{schild ? (
zusatz && <span>{zusatz}</span>
) : (
<>
{einstellungen.fahrzeugtitel} {zusatz && <span>{zusatz}</span>}
</>
)}
</div>
</div>
<p className="dm-fussnote">
Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten
Dateinamen, damit klar ist, was wohin gehört.
</p>
<span className="ads-eyebrow">
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
</span>
</Tile>
<div className="dm-raster">
<NaviKachel
titel="Fahrzeugdaten"
wert={fahrzeug.fin || "Technik und Ausstattung"}
beiKlick={() => geheZu("ident")}
/>
<NaviKachel
titel="Batterie"
wert={
fahrzeug.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "Kein Spannungssensor"
<Tile variant="button" chevron onClick={() => geheZu("ident")}>
<span className="ads-eyebrow">Fahrzeug</span>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
<Wertzeile label="Details" wert={fahrzeug.details || "—"} />
<Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung || "—"} />
<Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
<Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
</>
}
beiKlick={() => geheZu("battverlauf")}
/>
<NaviKachel titel="Service" wert="Termine und Wartungsplan" beiKlick={() => geheZu("service")} />
<NaviKachel
titel="Reifen"
wert={`${fahrzeug.reifen.aktiv} · ${deOderStrich(aktiverSatz.km)} km`}
beiKlick={() => geheZu("reifen")}
</Tile>
<Tile>
<span className="ads-eyebrow">Zustand</span>
<Werteliste
kinder={
<>
<Wertzeile
label="Kilometerstand"
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "unbekannt"}
/>
<Wertzeile
label="Tankfüllung"
wert={`${de(fahrzeug.tankPct)} % / ${de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} l`}
/>
<button type="button" className="dm-wertzeile dm-wertzeile--knopf" onClick={() => geheZu("battverlauf")}>
<dt>Batteriespannung</dt>
<dd>
{fahrzeug.batteriespannung != null ? `${de(fahrzeug.batteriespannung, 1)} V` : "unbekannt"}
<span className="dm-wertzeile__chevron" aria-hidden="true">
</span>
</dd>
</button>
</>
}
/>
<NaviKachel
titel="Versicherung & Steuer"
wert="Beitrag, Vertrag, Kfz-Steuer"
beiKlick={() => geheZu("vers")}
</Tile>
<Tile variant="button" chevron onClick={() => geheZu("service")}>
<span className="ads-eyebrow">Service</span>
<Werteliste
kinder={
service ? (
<Wertzeile
label={service.art}
wert={service.restKm !== null ? `${de(service.restKm)} km` : datum(service.datum)}
zusatz={service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : undefined}
/>
) : (
<Wertzeile label="Nächster Termin" wert="kein Eintrag im Wartungsplan" />
)
}
/>
</div>
</Tile>
<Tile variant="button" chevron onClick={() => geheZu("vers")}>
<span className="ads-eyebrow">Versicherung/Steuer</span>
<Werteliste
kinder={
<>
<Wertzeile
label="Versicherung"
wert={fahrzeug.versicherung["beitrag"] ? `${eur(fahrzeug.versicherung["beitrag"] as number)}` : "—"}
zusatz="im Jahr"
/>
<Wertzeile
label="Kfz-Steuer"
wert={fahrzeug.steuer["betrag"] ? `${eur(fahrzeug.steuer["betrag"] as number)}` : "—"}
zusatz={fahrzeug.steuer["zeitraum"] === "jährlich" ? "im Jahr" : (fahrzeug.steuer["zeitraum"] as string)}
/>
<Wertzeile
label="Summe"
wert={`${eur(((fahrzeug.versicherung["beitrag"] as number) || 0) + ((fahrzeug.steuer["betrag"] as number) || 0))}`}
/>
</>
}
/>
</Tile>
<Tile variant="button" chevron onClick={() => geheZu("reifen")}>
<span className="ads-eyebrow">Reifen</span>
<Werteliste
kinder={
<>
<Wertzeile
label={`montiert: ${fahrzeug.reifen.aktiv}räder`}
wert={(aktiverSatz["mass"] as string) || "—"}
/>
<Wertzeile label="Laufleistung" wert={deOderStrich(aktiverSatz.km) + " km"} />
<Wertzeile
label="Wechseltermin"
wert={datum(fahrzeug.reifen.wechsel["datum"] as string)}
zusatz={fahrzeug.reifen.wechsel["vereinbart"] ? "vereinbart" : ""}
/>
</>
}
/>
</Tile>
</>
)
}
+262 -110
View File
@@ -9,170 +9,322 @@
import { useState } from "react"
import { Accordion, ActionButton, Feld, IconButton, Seg, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, IconButton, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolPaket } from "../symbole"
import { SymbolEdit, SymbolPaket } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
export function Reifen() {
const { fahrzeug, api, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
const [gezeigt, setzeGezeigt] = useState<"Sommer" | "Winter" | null>(null)
const [nmOffen, setzeNmOffen] = useState(false)
const [nmEntwurf, setzeNmEntwurf] = useState("")
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [wechselVereinbart, setzeWechselVereinbart] = useState<boolean | null>(null)
if (!fahrzeug) return null
const aktiv = fahrzeug.reifen.aktiv
const anzeige = gezeigt ?? aktiv
const satz: Reifensatz = anzeige === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
// Lokaler Entwurf, solange der Nutzer nichts geändert hat - Panel-
// Verhalten: Datum/Häkchen speichern erst beim Klick auf "In den Kalender
// übernehmen" (data-ics="reifen" liest #rfDat/#rfOk erst dort aus).
const wechselDatumAnzeige = wechselDatum ?? wechsel.datum ?? ""
const wechselVereinbartAnzeige = wechselVereinbart ?? wechsel.vereinbart ?? false
const wechseln = async () => {
const ziel = aktiv === "Sommer" ? "winter" : "sommer"
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return
// Deckungsgleich mit data-nmspeichern im Panel: normales Profilfeld, kein
// eigener Dienst (anders als der Kilometerstand unten).
const anzugsmomentSpeichern = async () => {
const wert = parseInt(nmEntwurf, 10)
if (!Number.isNaN(wert) && wert > 0) {
await profilSpeichern({ fahrzeug: { ...fahrzeug, reifen: { ...fahrzeug.reifen, anzugsmoment: wert } } })
}
setzeNmOffen(false)
}
const kalenderUebernehmen = async () => {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: wechselDatumAnzeige, vereinbart: wechselVereinbartAnzeige } },
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
)
}
const kmSpeichern = async (satzSchluessel: SatzSchluessel) => {
const wert = parseFloat(kmEntwurf.replace(",", "."))
if (!Number.isNaN(wert) && wert >= 0) {
setzeLaeuft(true)
try {
await api.reifenKmSetzen(satzSchluessel, Math.round(wert * 10) / 10)
} finally {
setzeLaeuft(false)
}
}
setzeKmOffen(null)
}
// Deckungsgleich mit data-satz im Panel: direktes Umstellen ohne
// Rückfrage, das Backend rechnet den bisherigen Satz zuerst ab.
const montierenAuf = async (ziel: "Sommer" | "Winter") => {
setzeLaeuft(true)
try {
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
// rutschen, die noch auf dem alten gefahren wurden.
await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel })
await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel.toLowerCase() })
} finally {
setzeLaeuft(false)
}
}
const archivieren = async () => {
const archivieren = async (satzSchluessel: SatzSchluessel) => {
if (!bestaetigen("Neue Räder anlegen?")) return
setzeLaeuft(true)
try {
// Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand
// und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar
// leer - siehe reifen.py archivieren()).
await api.reifenArchivieren(anzeige === "Winter" ? "winter" : "sommer")
await api.reifenArchivieren(satzSchluessel)
} finally {
setzeLaeuft(false)
}
}
const wechseldatumSetzen = async (neuesDatum: string) => {
// Deckungsgleich mit data-rf im Panel: jedes Feld speichert sofort beim
// Verlassen (kein separater Entwurfs-/Speichern-Schritt wie bei den
// Archiv-Sätzen unten - dort schützt das einen womöglich seither
// fortgeschriebenen km-Stand, hier gibt es diesen Konflikt nicht).
const feldSpeichern = async (satzSchluessel: SatzSchluessel, feld: string, wert: string) => {
const bisher = satzSchluessel === "winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
await profilSpeichern({
fahrzeug: {
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
},
fahrzeug: { ...fahrzeug, reifen: { ...fahrzeug.reifen, [satzSchluessel]: { ...bisher, [feld]: wert } } },
})
}
const satzTile = (satzSchluessel: SatzSchluessel) => {
const satz: Reifensatz = satzSchluessel === "winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const satzLabel = satzSchluessel === "winter" ? "Winterräder" : "Sommerräder"
const radLabel = satzSchluessel === "winter" ? "Winterrad" : "Sommerrad"
const istAktiv = aktiv.toLowerCase() === satzSchluessel
return (
<Tile key={satzSchluessel}>
<div className="dm-radkopf-row">
<div className="dm-radkopf">
<span className="dm-tile-headline">{satzLabel}</span>
<div className="dm-reifenzeile">
<IconButton
aria-label={`${satzLabel} Kilometerstand ändern`}
onClick={() => {
setzeKmEntwurf(String((satz["km"] as number) ?? ""))
setzeKmOffen((o) => (o === satzSchluessel ? null : satzSchluessel))
}}
>
<SymbolEdit groesse={16} />
</IconButton>
{satz["km"] != null ? de(satz["km"] as number) : ""}
<span className="ads-fig__unit">km</span>
</div>
</div>
<div className="dm-radfoto">
<Bild
datei={satzSchluessel === "winter" ? "rad-winter.webp" : "rad-sommer.webp"}
beschreibung={radLabel}
name={radLabel}
/>
</div>
</div>
{kmOffen === satzSchluessel && (
<div className="dm-bearbeiten-block">
<p className="dm-fussnote">
Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem Satz zählen ab
hier weiter dazu.
</p>
<Feld label="Neuer Wert" unit="km" last>
<input
className="dm-eingabe"
type="number"
step={1}
min={0}
value={kmEntwurf}
onChange={(e) => setzeKmEntwurf(e.target.value)}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void kmSpeichern(satzSchluessel)} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<ActionButton onClick={() => setzeKmOffen(null)}>Abbrechen</ActionButton>
</div>
</div>
)}
<div className="dm-montiert-reihe">
<button
type="button"
className={`dm-montiert${istAktiv ? " dm-montiert--an" : ""}`}
onClick={() => void montierenAuf(satzSchluessel === "winter" ? "Winter" : "Sommer")}
disabled={laeuft}
>
Montiert
</button>
<IconButton
className="dm-archivbtn"
aria-label="Neue Räder anlegen"
onClick={() => void archivieren(satzSchluessel)}
disabled={laeuft}
>
<SymbolPaket groesse={18} />
</IconButton>
</div>
<div style={{ marginTop: 12 }}>
<Feld label="Hersteller">
<input
className="dm-eingabe"
defaultValue={(satz["marke"] as string) ?? ""}
placeholder="noch offen"
onBlur={(e) => void feldSpeichern(satzSchluessel, "marke", e.target.value)}
/>
</Feld>
<Feld label="Modell">
<input
className="dm-eingabe"
defaultValue={(satz["modell"] as string) ?? ""}
placeholder="noch offen"
onBlur={(e) => void feldSpeichern(satzSchluessel, "modell", e.target.value)}
/>
</Feld>
<Feld label="Größe">
<input
className="dm-eingabe"
defaultValue={(satz["mass"] as string) ?? ""}
placeholder="noch offen"
onBlur={(e) => void feldSpeichern(satzSchluessel, "mass", e.target.value)}
/>
</Feld>
<Feld label="DOT">
<input
className="dm-eingabe"
defaultValue={(satz["dot"] as string) ?? ""}
placeholder="noch offen"
onBlur={(e) => void feldSpeichern(satzSchluessel, "dot", e.target.value)}
/>
</Feld>
<Feld label="Solldruck vorn / hinten" unit="bar">
<input
className="dm-eingabe druckfeld"
defaultValue={(satz["druck_vorne"] as string) ?? ""}
placeholder=""
onBlur={(e) => void feldSpeichern(satzSchluessel, "druck_vorne", e.target.value)}
/>
<span>/</span>
<input
className="dm-eingabe druckfeld"
defaultValue={(satz["druck_hinten"] as string) ?? ""}
placeholder=""
onBlur={(e) => void feldSpeichern(satzSchluessel, "druck_hinten", e.target.value)}
/>
</Feld>
{/* Volle Breite statt Label+Eingabe in einer Zeile (wie Versicherung.tsx'
Vertragsnotiz) - Parität zum Panel (.feld--breit), sonst reißt ein
längerer Kommentar wie "Eingelagert beim Autohaus" ab. */}
<div className="ads-feld" style={{ display: "block", borderBottom: 0 }}>
<label style={{ display: "block", marginBottom: 8 }}>Kommentar</label>
<input
className="dm-eingabe"
style={{ width: "100%", textAlign: "left" }}
defaultValue={(satz["kommentar"] as string) ?? ""}
placeholder="noch offen"
onBlur={(e) => void feldSpeichern(satzSchluessel, "kommentar", e.target.value)}
/>
</div>
</div>
</Tile>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Montiert</span>
<span className="ads-eyebrow">Anzugsmoment der Radschrauben</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">{aktiv}</div>
<ActionButton onClick={() => void wechseln()} disabled={laeuft}>
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`}
</ActionButton>
<div className="ads-fig" style={{ fontSize: 34, marginTop: 12 }}>
{fahrzeug.reifen.anzugsmoment != null ? de(fahrzeug.reifen.anzugsmoment) : ""}
<span className="ads-fig__unit">Nm</span>
</div>
<IconButton
aria-label="Anzugsmoment ändern"
onClick={() => {
setzeNmEntwurf(String(fahrzeug.reifen.anzugsmoment ?? ""))
setzeNmOffen((o) => !o)
}}
>
<SymbolEdit groesse={16} />
</IconButton>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Anzugsmoment der Radschrauben"
wert={
fahrzeug.reifen.anzugsmoment != null
? `${de(fahrzeug.reifen.anzugsmoment)} Nm`
: "—"
}
{nmOffen && (
<div className="dm-bearbeiten-block">
<Feld label="Neuer Wert" unit="Nm" last>
<input
className="dm-eingabe"
type="number"
step={5}
min={0}
value={nmEntwurf}
onChange={(e) => setzeNmEntwurf(e.target.value)}
/>
<Wertzeile
label="Nächster Wechseltermin"
wert={wechsel.datum ? datum(wechsel.datum) : "—"}
/>
</>
}
/>
<Feld label="Wechseltermin" last>
<input
className="dm-eingabe"
type="date"
value={wechsel.datum ?? ""}
onChange={(e) => void wechseldatumSetzen(e.target.value)}
/>
</Feld>
{wechsel.datum && (
<div style={{ marginTop: 14 }}>
<ActionButton
onClick={() =>
kalenderDateiLaden(
"Reifenwechsel",
wechsel.datum!,
`Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`,
)
}
>
In den Kalender eintragen
</ActionButton>
</Feld>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void anzugsmomentSpeichern()}>Speichern</ActionButton>
<ActionButton onClick={() => setzeNmOffen(false)}>Abbrechen</ActionButton>
</div>
</div>
)}
</Tile>
<Seg
options={[
{ value: "Sommer", label: "Sommer" },
{ value: "Winter", label: "Winter" },
]}
value={anzeige}
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
/>
<Tile>
<span className="ads-eyebrow">
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"}
</span>
<Bild
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"}
beschreibung={`${anzeige}rad`}
name={`${anzeige}rad`}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: -6 }}>
<IconButton aria-label="Neue Räder anlegen" onClick={() => void archivieren()} disabled={laeuft}>
<SymbolPaket groesse={18} />
</IconButton>
<span className="ads-eyebrow">Wechseltermin</span>
<Feld label="Geplant am">
<input
className="dm-eingabe"
type="date"
value={wechselDatumAnzeige}
onChange={(e) => setzeWechselDatum(e.target.value)}
/>
</Feld>
<Feld label="Termin vereinbart" last>
<Switch
checked={wechselVereinbartAnzeige}
onChange={(checked) => setzeWechselVereinbart(checked)}
aria-label="Termin vereinbart"
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}>
In den Kalender übernehmen
</ActionButton>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Gefahrene Strecke" wert={`${deOderStrich(satz["km"] as number)} km`} />
<Wertzeile label="Größe" wert={(satz["mass"] as string) || "—"} />
<Wertzeile
label="Marke und Modell"
wert={
[satz["marke"], satz["modell"]].filter(Boolean).join(" ") || "—"
}
/>
<Wertzeile label="DOT" wert={(satz["dot"] as string) || "—"} />
<Wertzeile
label="Luftdruck"
wert={`${(satz["druck_vorne"] as string) || "—"} / ${(satz["druck_hinten"] as string) || "—"} bar`}
zusatz="vorne / hinten"
/>
{Boolean(satz["kommentar"]) && (
<Wertzeile label="Notiz" wert={satz["kommentar"] as string} />
)}
</>
}
/>
<p className="dm-fussnote">
Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
rechnet sie immer dem gerade montierten Satz zu.
</p>
</Tile>
{satzTile("sommer")}
{satzTile("winter")}
{fahrzeug.reifen.archiv.length > 0 && (
<Tile>
<span className="ads-eyebrow">Archiv</span>
+555
View File
@@ -0,0 +1,555 @@
/**
* Standort — Live-GPS-Position des Fahrzeugs. Vorschau-Kachel auf der
* Übersicht (`StandortVorschau`, eingebettet dort) plus Vollbild-Karte mit
* eigenem Marker, Abstand, Adresse, Fahrzustand und Route/Teilen-Aktionen.
* Vorlage: `vStandort()`/`standortKachel()` im alten Panel — bis 2026-08-29
* fehlte diese ganze Funktion hier komplett (Parität-Audit).
*
* Leaflet kommt wie bei `Karte.tsx` aus dem Bündel, nicht vom CDN.
*/
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import { useDaten } from "../daten/DatenKontext"
import { de } from "../format"
import type { Fahrzeug } from "../daten/profilAdapter"
import { SymbolTanken } from "../symbole"
import "leaflet/dist/leaflet.css"
const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
const KACHEL_ZUSATZ = "© OpenStreetMap contributors"
const SAT_KACHEL_URL =
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
const SAT_ZUSATZ = "© Esri"
// Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim übernommen wie die
// übrigen CI-Icons in diesem Projekt — siehe symbole.tsx SymbolOelwechsel/-
// Inspektion) — hier lokal, weil sie nur für die Kartensteuerung/den
// Fahrzeugmarker gebraucht werden, nicht als allgemeine UI-Symbole.
const CI = {
mapLayer:
'M11.7 19.4-8.97-5.66.54-.85 8.96 5.67c.17.1.37.1.54 0l8.96-5.67.54.85-8.97 5.66a1.5 1.5 0 0 1-1.6 0m.53-3.84c.17.1.37.1.54 0l8.3-5.25-8.3-5.23a.5.5 0 0 0-.54 0l-8.3 5.23zm-.53.84-9.64-6.09 9.64-6.08a1.5 1.5 0 0 1 1.6 0l9.64 6.08-9.64 6.1c-.49.3-1.11.3-1.6 0Z',
carSearch:
'M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z',
gps: 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z',
selectAll:
'M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z',
planRoute:
'm18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z',
upload:
'M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z',
parking:
'M0 2.5A2.5 2.5 0 0 1 2.5 0H23v20.5a2.5 2.5 0 0 1-2.5 2.5H0zM2.5 1C1.67 1 1 1.67 1 2.5V22h19.5c.83 0 1.5-.67 1.5-1.5V1zm11.59 6.35C13.6 7 13.02 7 12.5 7H8v6h4.5c.52 0 1.11-.01 1.59-.35.44-.3.91-1 .91-2.65 0-1.66-.47-2.34-.91-2.65M8 14v3H7V6h5.53c.5 0 1.38 0 2.13.53C15.47 7.09 16 8.16 16 10s-.53 2.9-1.34 3.47a3.6 3.6 0 0 1-2.13.53z',
// poi-car-l: Umriss (Ring) + Vollton-Silhouette dahinter, siehe
// fahrzeugMarkerSVG() im Panel — reine Konturformen sind auf Kartenkacheln
// sonst praktisch unsichtbar.
poiCarL:
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z',
poiCarLSil:
'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5',
} as const
function ciIcon(d: string, px: number): string {
return `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="currentColor" d="${d}"/></svg>`
}
function fahrzeugMarkerHtml(px = 38): string {
return `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 48 48" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${CI.poiCarLSil}"/><path fill="currentColor" d="${CI.poiCarL}"/></svg></div>`
}
/* Haversine-Näherung, wie im Panel (distanzMeter()/distanzText()). */
function distanzMeter(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
const R = 6371000
const rad = Math.PI / 180
const dLat = (b.lat - a.lat) * rad
const dLon = (b.lon - a.lon) * rad
const s =
Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
}
function distanzText(m: number | null): string {
return m == null ? "" : m < 1000 ? `${Math.round(m)} m` : `${de(m / 1000, 1)} km`
}
function dauerSeitText(ts: number | null): string {
if (ts == null) return "unbekannt"
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000))
const tage = Math.floor(sekunden / 86400)
const stunden = Math.floor((sekunden % 86400) / 3600)
const minuten = Math.floor((sekunden % 3600) / 60)
if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`
return `${minuten} Min.`
}
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
standortZustand() im Panel. */
function standortZustand(
fahrzeug: Fahrzeug,
fahrten: readonly { ts_end: string }[],
pauseMin: number,
): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } {
let neuesteTs: number | null = null
for (const t of fahrten) {
const ts = new Date(t.ts_end).getTime()
if (!Number.isNaN(ts) && (neuesteTs === null || ts > neuesteTs)) neuesteTs = ts
}
if (fahrzeug.zuendung === true) return { faehrt: true, unbekannt: false, seitTs: null, geparkt: false }
const minuten = neuesteTs != null ? (Date.now() - neuesteTs) / 60000 : null
return {
faehrt: false,
unbekannt: fahrzeug.zuendung == null,
seitTs: neuesteTs,
geparkt: minuten != null && minuten >= pauseMin,
}
}
/* Reverse-Geocoding über Nominatim (OpenStreetMap), wie standortAdresseAufloesen()
im Panel — Cache-Schlüssel auf 3 Nachkommastellen (~110 m) gerundet, damit
erneutes Öffnen an derselben Stelle keinen zweiten Abruf auslöst. Modulweiter
Cache (nicht in localStorage wie die Tankstellen-Adresssuche im Panel — die
Fahrzeugposition ändert sich laufend, ein dauerhafter Cache brächte nichts). */
let adressCacheKey: string | null = null
let adressCacheWert: string | null = null
async function standortAdresseAufloesen(lat: number, lon: number): Promise<string | null> {
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`
if (adressCacheKey === key) return adressCacheWert
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`,
{ headers: { "Accept-Language": "de" } },
)
const daten = (await res.json()) as { address?: Record<string, string>; display_name?: string }
const a = daten.address ?? {}
const strasse = [a["road"], a["house_number"]].filter(Boolean).join(" ")
const ort = [a["postcode"], a["city"] ?? a["town"] ?? a["village"]].filter(Boolean).join(" ")
adressCacheWert = [strasse, ort].filter(Boolean).join(", ") || daten.display_name || null
} catch {
// Bewusst NICHT den Schlüssel setzen — ein Netzfehler soll beim nächsten
// Öffnen erneut versucht werden, nicht dauerhaft als "erledigt" gelten.
return null
}
adressCacheKey = key
return adressCacheWert
}
interface EigenePosition {
lat: number
lon: number
}
function eigenePositionErfassen(
setzePosition: (p: EigenePosition | null) => void,
setzeFehler: (f: string | null) => void,
): void {
if (!navigator.geolocation) {
setzeFehler("GPS offline")
return
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setzeFehler(null)
setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude })
},
(err) => {
// Diese Meldung betrifft den Standort DIESES Geräts, nicht des Fahrzeugs
// — "GPS-Freigabe fehlt" nur beim genau dafür zuständigen Fehlercode,
// sonst "GPS offline" (deckungsgleich mit dem Panel).
setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline")
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 },
)
}
/** Kleine, nicht interaktive Kartenvorschau auf der Übersicht — Vorlage:
standortKachel()/initVorschauMap() im Panel. */
export function StandortVorschau({ geheZu }: { geheZu: () => void }) {
const { fahrzeug } = useDaten()
const behaelter = useRef<HTMLDivElement | null>(null)
const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null
useEffect(() => {
const element = behaelter.current
if (!element || !bekannt || !fahrzeug) return
let karte: import("leaflet").Map | null = null
let abgebrochen = false
void import("leaflet").then((L) => {
if (abgebrochen || !element) return
karte = L.map(element, {
attributionControl: false,
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
touchZoom: false,
})
L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
karte.setView([fahrzeug.standortLat!, fahrzeug.standortLon!], 14)
const icon = L.divIcon({
className: "dm-fahrzeug-pin-wrap",
html: fahrzeugMarkerHtml(32),
iconSize: [32, 32],
iconAnchor: [15, 31],
})
L.marker([fahrzeug.standortLat!, fahrzeug.standortLon!], { icon }).addTo(karte)
setTimeout(() => karte?.invalidateSize(), 60)
})
return () => {
abgebrochen = true
karte?.remove()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bekannt, fahrzeug?.standortLat, fahrzeug?.standortLon])
return (
<button type="button" className="dm-standort-kachelbtn" onClick={geheZu} aria-label="Standort in Vollbild öffnen">
<div className="dm-standort-vorschau" ref={behaelter} />
{!bekannt && <div className="dm-standort-leer">Kein GPS-Signal vom Fahrzeug</div>}
</button>
)
}
/** Vollbild-Standortansicht — Vorlage: vStandort() im Panel. */
export function Standort() {
const { fahrzeug, einstellungen, fahrten } = useDaten()
const behaelter = useRef<HTMLDivElement | null>(null)
const kartenRef = useRef<import("leaflet").Map | null>(null)
const fahrzeugMarkerRef = useRef<import("leaflet").Marker | null>(null)
const userMarkerRef = useRef<import("leaflet").Marker | null>(null)
const kachelRef = useRef<import("leaflet").TileLayer | null>(null)
const leafletRef = useRef<typeof import("leaflet") | null>(null)
const [satellit, setzeSatellit] = useState(false)
const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null)
const [positionsFehler, setzePositionsFehler] = useState<string | null>(null)
const [adresse, setzeAdresse] = useState<string | null>(null)
const [blattOffen, setzeBlattOffen] = useState(false)
const blattRef = useRef<HTMLDivElement | null>(null)
// Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das
// Blatt folgt der Fingerbewegung direkt (Ref statt State - kein Re-Render
// pro Pixel), erst pointerup entscheidet über den endgültigen Zustand.
const zugRef = useRef<{ y0: number; offenBeimStart: boolean } | null>(null)
const blattHub = () => {
const el = blattRef.current
if (!el) return 0
const peek = parseFloat(getComputedStyle(el).getPropertyValue("--dm-standort-peek")) || 96
return Math.max(0, el.offsetHeight - peek)
}
const griffPointerDown = (e: ReactPointerEvent) => {
zugRef.current = { y0: e.clientY, offenBeimStart: blattOffen }
const el = blattRef.current
if (el) el.style.transition = "none"
}
const griffPointerMove = (e: ReactPointerEvent) => {
const zug = zugRef.current
const el = blattRef.current
if (!zug || !el) return
const dy = e.clientY - zug.y0
const hub = blattHub()
const basis = zug.offenBeimStart ? 0 : hub
const versatz = Math.max(0, Math.min(hub, basis + dy))
el.style.transform = `translateY(${versatz}px)`
}
const griffGesteBeenden = () => {
const el = blattRef.current
if (el) {
el.style.transition = ""
el.style.transform = ""
}
zugRef.current = null
}
const griffPointerUp = (e: ReactPointerEvent) => {
const zug = zugRef.current
if (!zug) return
const dy = e.clientY - zug.y0
griffGesteBeenden()
if (Math.abs(dy) > 40) setzeBlattOffen(dy < 0)
else setzeBlattOffen((o) => !o)
}
const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null
const fahrzeugPos = bekannt ? { lat: fahrzeug!.standortLat!, lon: fahrzeug!.standortLon! } : null
// Karte einmalig aufbauen.
useEffect(() => {
const element = behaelter.current
if (!element) return
let abgebrochen = false
void import("leaflet").then((L) => {
if (abgebrochen || !element) return
leafletRef.current = L
const karte = L.map(element, { attributionControl: false, zoomControl: false })
kartenRef.current = karte
kachelRef.current = L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15)
else karte.setView([51.16, 10.45], 5)
setTimeout(() => karte.invalidateSize(), 60)
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
})
return () => {
abgebrochen = true
kartenRef.current?.remove()
kartenRef.current = null
}
// Nur beim ersten Aufbau — Positions-/Kachel-Updates laufen über die
// Effekte unten, damit die Karte nicht bei jedem Datenpush neu entsteht.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Kachel-Ebene wechseln (Straße/Satellit).
useEffect(() => {
const L = leafletRef.current
const karte = kartenRef.current
if (!L || !karte) return
if (kachelRef.current) karte.removeLayer(kachelRef.current)
kachelRef.current = satellit
? L.tileLayer(SAT_KACHEL_URL, { attribution: SAT_ZUSATZ, maxZoom: 19 }).addTo(karte)
: L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
}, [satellit])
// Fahrzeugmarker zeichnen/aktualisieren.
useEffect(() => {
const L = leafletRef.current
const karte = kartenRef.current
if (!L || !karte) return
if (fahrzeugMarkerRef.current) {
karte.removeLayer(fahrzeugMarkerRef.current)
fahrzeugMarkerRef.current = null
}
if (fahrzeugPos) {
const icon = L.divIcon({
className: "dm-fahrzeug-pin-wrap",
html: fahrzeugMarkerHtml(38),
iconSize: [38, 38],
iconAnchor: [19, 36],
})
const marker = L.marker([fahrzeugPos.lat, fahrzeugPos.lon], { icon }).addTo(karte)
marker.on("click", () => setzeBlattOffen(true))
fahrzeugMarkerRef.current = marker
}
}, [fahrzeugPos?.lat, fahrzeugPos?.lon])
// Eigene Position zeichnen/aktualisieren.
useEffect(() => {
const L = leafletRef.current
const karte = kartenRef.current
if (!L || !karte) return
if (userMarkerRef.current) {
karte.removeLayer(userMarkerRef.current)
userMarkerRef.current = null
}
if (eigenePosition) {
const icon = L.divIcon({
className: "dm-user-pin-wrap",
html: `<div class="dm-user-pin"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
})
userMarkerRef.current = L.marker([eigenePosition.lat, eigenePosition.lon], {
icon,
zIndexOffset: -100,
}).addTo(karte)
}
}, [eigenePosition?.lat, eigenePosition?.lon])
// Anfangsausschnitt: beide Punkte zeigen, sobald beide bekannt sind.
useEffect(() => {
const karte = kartenRef.current
if (!karte) return
if (fahrzeugPos && eigenePosition) {
karte.fitBounds(
[
[fahrzeugPos.lat, fahrzeugPos.lon],
[eigenePosition.lat, eigenePosition.lon],
],
{ padding: [64, 64], maxZoom: 16 },
)
}
// Nur beim ersten Mal, an dem beide Punkte vorliegen — ein späterer Push
// (Fahrzeug bewegt sich) soll den Nutzer nicht aus einer manuellen
// Kartenposition herausreißen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [!!fahrzeugPos, !!eigenePosition])
// Adresse nachladen, sobald/wann immer sich die Fahrzeugposition ändert.
useEffect(() => {
if (!fahrzeugPos) {
setzeAdresse(null)
return
}
let abgebrochen = false
void standortAdresseAufloesen(fahrzeugPos.lat, fahrzeugPos.lon).then((a) => {
if (!abgebrochen && a) setzeAdresse(a)
})
return () => {
abgebrochen = true
}
}, [fahrzeugPos?.lat, fahrzeugPos?.lon])
if (!fahrzeug || !einstellungen) return null
const distanz = eigenePosition && fahrzeugPos ? distanzMeter(eigenePosition, fahrzeugPos) : null
const distanzAnzeige = !bekannt
? ""
: eigenePosition
? `${distanzText(distanz)} entfernt`
: (positionsFehler ?? "Abstand wird ermittelt …")
const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : ""
const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten)
const zustand = standortZustand(fahrzeug, fahrten, einstellungen.pauseMin)
const kartenlink = fahrzeugPos
? `https://www.google.com/maps/dir/?api=1&destination=${fahrzeugPos.lat},${fahrzeugPos.lon}`
: null
const teilen = () => {
if (!kartenlink) return
const titel = `${einstellungen.fahrzeugtitel} · Standort`
if (navigator.share) {
void navigator.share({ title: titel, url: kartenlink }).catch(() => {
/* vom Nutzer abgebrochen */
})
return
}
void navigator.clipboard.writeText(kartenlink).catch(() => {
/* Zwischenablage nicht verfügbar — der Link steht in der Adressleiste des neuen Tabs */
})
}
return (
<div className="dm-standort-vollbild">
<div className="dm-standort-karte" ref={behaelter} />
<div className="dm-kartensteuerung">
<button
type="button"
onClick={() => setzeSatellit((s) => !s)}
aria-pressed={satellit}
aria-label={satellit ? "Satellitenansicht, zur Straßenkarte wechseln" : "Straßenkarte, zur Satellitenansicht wechseln"}
dangerouslySetInnerHTML={{ __html: ciIcon(CI.mapLayer, 22) }}
/>
<button
type="button"
onClick={() => fahrzeugPos && kartenRef.current?.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], Math.max(kartenRef.current.getZoom(), 15))}
aria-label="Fahrzeug auf der Karte suchen"
dangerouslySetInnerHTML={{ __html: ciIcon(CI.carSearch, 22) }}
/>
<button
type="button"
onClick={() => {
if (!eigenePosition) {
eigenePositionErfassen(setzeEigenePosition, setzePositionsFehler)
return
}
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
}}
aria-label="Auf eigenen Standort zentrieren"
dangerouslySetInnerHTML={{ __html: ciIcon(CI.gps, 22) }}
/>
<button
type="button"
onClick={() => {
const karte = kartenRef.current
if (!karte) return
if (fahrzeugPos && eigenePosition) {
karte.fitBounds([[fahrzeugPos.lat, fahrzeugPos.lon], [eigenePosition.lat, eigenePosition.lon]], {
padding: [64, 64],
maxZoom: 16,
})
} else if (fahrzeugPos) karte.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], 15)
else if (eigenePosition) karte.flyTo([eigenePosition.lat, eigenePosition.lon], 15)
}}
aria-label="Fahrzeug und eigenen Standort gemeinsam zeigen"
dangerouslySetInnerHTML={{ __html: ciIcon(CI.selectAll, 22) }}
/>
</div>
<div
ref={blattRef}
className={blattOffen ? "dm-standortmenu dm-standortmenu--offen" : "dm-standortmenu"}
>
<div
className="dm-standortmenu__griffzone"
onPointerDown={griffPointerDown}
onPointerMove={griffPointerMove}
onPointerUp={griffPointerUp}
onPointerCancel={griffGesteBeenden}
>
<button
type="button"
className="dm-standortmenu__griff"
// Tippen/Klicken wird schon von den Pointer-Handlern der
// Griffzone erledigt (dy < 40px zählt dort als Tipp) - ein
// zusätzliches onClick würde bei Maus/Touch doppelt umschalten.
// Nur für Tastaturbedienung (Enter/Leertaste), die keinen
// Pointer-Event auslöst.
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setzeBlattOffen((o) => !o)
}
}}
aria-expanded={blattOffen}
aria-label={blattOffen ? "Fahrzeugdetails einklappen" : "Fahrzeugdetails aufklappen"}
/>
<button
type="button"
className="dm-standortmenu__schliessen"
onClick={() => setzeBlattOffen(false)}
aria-label="Schließen"
>
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</button>
<div className="dm-standortmenu__kopf">
<div className="dm-standortmenu__name">{einstellungen.fahrzeugtitel}</div>
<div className="dm-standortmenu__distanz">{distanzAnzeige}</div>
</div>
</div>
<div className="dm-standortmenu__adresse">{adresseAnzeige}</div>
<div className="dm-standortmenu__status">
{zustand.faehrt ? (
<>
<span className="dm-standortmenu__punkt" />
<span>Fahrzeug fährt</span>
</>
) : (
<>
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.parking, 16) }} />
<span>
{zustand.unbekannt
? "Fahrzustand unbekannt"
: zustand.seitTs != null
? `Geparkt seit ${dauerSeitText(zustand.seitTs)}`
: "Fahrzeug steht"}
</span>
</>
)}
</div>
<div className="dm-standortmenu__werte">
<SymbolTanken groesse={16} />
<span>{de(fahrzeug.tankPct)} %</span>
<span>{fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"}</span>
</div>
<div className="dm-standortmenu__aktionen">
<a
className="dm-standort-pille dm-standort-pille--primaer"
href={kartenlink ?? undefined}
target="_blank"
rel="noopener"
aria-disabled={!kartenlink}
style={kartenlink ? undefined : { pointerEvents: "none", opacity: 0.4 }}
>
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.planRoute, 15) }} />
Route
</a>
<button type="button" className="dm-standort-pille" onClick={teilen} disabled={!bekannt}>
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.upload, 15) }} />
Teilen
</button>
</div>
</div>
</div>
)
}
+178 -4
View File
@@ -9,12 +9,13 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Pill, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import type { Tankvorgang } from "../api"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface BelegErgebnis {
ok?: boolean
@@ -27,6 +28,7 @@ interface BelegErgebnis {
export function TankDetail({ id }: { id: string | undefined }) {
const { tankvorgaenge, api } = useDaten()
const tank = tankvorgaenge.find((t) => t.tank_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
if (!tank) {
return (
@@ -37,12 +39,21 @@ export function TankDetail({ id }: { id: string | undefined }) {
)
}
if (bearbeitenOffen) {
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
}
return (
<>
<Tile>
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
<button type="button" className="dm-textknopf" onClick={() => setzeBearbeitenOffen(true)}>
Bearbeiten
</button>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
<div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(tank.liters, 2)}
<span className="dm-detail__einheit">l</span>
</div>
@@ -92,6 +103,169 @@ export function TankDetail({ id }: { id: string | undefined }) {
)
}
/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein <input type="datetime-local">. */
function alsDatetimeLocal(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const versatz = d.getTimezoneOffset() * 60_000
return new Date(d.getTime() - versatz).toISOString().slice(0, 16)
}
/** Bearbeiten-Formular für einen bestehenden Tankvorgang - dieselben Felder
wie tankFelder() im Panel (Zeitpunkt, Kilometerstand, Distanz, Kraftstoff,
Liter, Kosten, SmartDeal-Ersparnis, Tankstelle). Fehlte hier bisher
vollständig - der einzige Weg, einen Wert zu korrigieren, war ein neuer
Beleg-Upload. */
function TankBearbeiten({ tank, beiFertig }: { tank: Tankvorgang; beiFertig: () => void }) {
const { api, fahrzeug } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState(alsDatetimeLocal(tank.ts))
const [km, setzeKm] = useState(tank.odometer_km != null ? String(tank.odometer_km) : "")
const [distanz, setzeDistanz] = useState(tank.distance_km != null ? String(tank.distance_km) : "")
const [kraftstoff, setzeKraftstoff] = useState(tank.fuel_type ?? KRAFTSTOFFSORTEN[0])
const [liter, setzeLiter] = useState(tank.liters != null ? String(tank.liters) : "")
const [kosten, setzeKosten] = useState(tank.fuel_total_eur != null ? String(tank.fuel_total_eur) : "")
const [ersparnis, setzeErsparnis] = useState(tank.discount != null ? String(tank.discount) : "")
const [station, setzeStation] = useState(tank.station_name ?? "")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
const speichern = async () => {
setzeFehler(null)
const literZahl = Number(liter.replace(",", "."))
const kostenZahl = Number(kosten.replace(",", "."))
if (!Number.isFinite(literZahl) || literZahl <= 0) {
setzeFehler("Bitte die getankte Menge in Litern angeben.")
return
}
if (!Number.isFinite(kostenZahl) || kostenZahl <= 0) {
setzeFehler("Bitte den Gesamtbetrag angeben.")
return
}
setzeLaeuft(true)
try {
const kmZahl = Number(km.replace(",", "."))
const distanzZahl = Number(distanz.replace(",", "."))
const ersparnisZahl = Number(ersparnis.replace(",", "."))
await api.tankvorgangAktualisieren(tank.tank_id, {
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : tank.ts,
liter: literZahl,
kosten: kostenZahl,
kraftstoff,
station: station || null,
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
distanz: Number.isFinite(distanzZahl) && distanz ? distanzZahl : null,
...(smartdealAktiv
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
: {}),
})
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<div className="dm-abschnitt" style={{ marginBottom: 0 }}>
<span className="ads-eyebrow">Tankvorgang bearbeiten</span>
<button type="button" className="dm-textknopf" onClick={beiFertig} disabled={laeuft}>
Abbrechen
</button>
</div>
<Feld label="Zeitpunkt">
<input
className="dm-eingabe"
type="datetime-local"
value={zeitpunkt}
onChange={(e) => setzeZeitpunkt(e.target.value)}
/>
</Feld>
<Feld label="Kilometerstand" unit="km">
<input
className="dm-eingabe"
inputMode="numeric"
placeholder="wird ergänzt"
value={km}
onChange={(e) => setzeKm(e.target.value)}
/>
</Feld>
<Feld label="Gefahrene Distanz" unit="km">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="aus km-Stand"
value={distanz}
onChange={(e) => setzeDistanz(e.target.value)}
/>
</Feld>
<Feld label="Kraftstoff">
<select
className="dm-auswahl"
value={kraftstoff}
onChange={(e) => setzeKraftstoff(e.target.value)}
>
{KRAFTSTOFFSORTEN.map((sorte) => (
<option key={sorte} value={sorte}>
{sorte}
</option>
))}
</select>
</Feld>
<Feld label="Getankte Liter" unit="l">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={liter}
onChange={(e) => setzeLiter(e.target.value)}
/>
</Feld>
<Feld label="Kosten (real, nach Rabatt)" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={kosten}
onChange={(e) => setzeKosten(e.target.value)}
/>
</Feld>
{smartdealAktiv && (
<Feld label="SmartDeal-Ersparnis" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={ersparnis}
onChange={(e) => setzeErsparnis(e.target.value)}
/>
</Feld>
)}
<Feld label="Tankstelle" last>
<input
className="dm-eingabe"
placeholder="Name"
value={station}
onChange={(e) => setzeStation(e.target.value)}
/>
</Feld>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
function BelegBereich({
tankId,
belegDatei,
+78 -8
View File
@@ -14,14 +14,16 @@ import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue"
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { tankvorgaenge, api } = useDaten()
const { tankvorgaenge, fahrzeug, einstellungen, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
return (
<>
@@ -29,7 +31,9 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<span className="ads-eyebrow">
{tankvorgaenge.length === 0
? "Keine Tankvorgänge"
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`}
: tankvorgaenge.length === 1
? "1 Tankvorgang"
: `${de(tankvorgaenge.length)} Tankvorgänge`}
</span>
<button
type="button"
@@ -50,11 +54,36 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
/>
)}
{gruppen.map((jahr) => (
{gruppen.map((jahr) => {
const ersparnisJahr = summe(jahr.eintraege, (t) => t.discount ?? 0)
return (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))}`}
summary={
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 5 }}>
<span style={{ fontSize: 17, color: "var(--fg)" }}>
{eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))}
</span>
<span>
{de(summe(jahr.eintraege, (t) => t.liters ?? 0), 1)} l · Ø{" "}
{de(schnittpreis(jahr.eintraege), 2)} /l
</span>
{smartdealAktiv && ersparnisJahr > 0 && (
<Pill>
<img
src={markenlogoUrl()}
alt={einstellungen?.markenname ?? "Logo"}
style={{ height: 14, verticalAlign: "middle", marginRight: 6 }}
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
{eur(ersparnisJahr)}
</Pill>
)}
</span>
}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => {
@@ -64,7 +93,14 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={schnitt > 0 ? `${eur(schnitt)} €/l` : "—"}
summary={
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
<span style={{ color: "var(--fg)" }}>
{eur(summe(monat.eintraege, (t) => t.fuel_total_eur ?? 0))}
</span>
<span>{schnitt > 0 ? `Ø ${eur(schnitt)} €/l` : "—"}</span>
</span>
}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((tank) => (
@@ -102,21 +138,26 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
)
})}
</Accordion>
))}
)
})}
</>
)
}
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const { api, fahrzeug } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState("")
const [liter, setzeLiter] = useState("")
const [kosten, setzeKosten] = useState("")
const [kraftstoff, setzeKraftstoff] = useState<string>(KRAFTSTOFFSORTEN[0])
const [station, setzeStation] = useState("")
const [km, setzeKm] = useState("")
const [ersparnis, setzeErsparnis] = useState("")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
const speichern = async () => {
setzeFehler(null)
const literZahl = Number(liter.replace(",", "."))
@@ -132,12 +173,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
setzeLaeuft(true)
try {
const kmZahl = Number(km.replace(",", "."))
const ersparnisZahl = Number(ersparnis.replace(",", "."))
await api.tankvorgangAnlegen({
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
liter: literZahl,
kosten: kostenZahl,
kraftstoff,
station: station || null,
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
...(smartdealAktiv
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
: {}),
})
beiFertig()
} catch (ursache) {
@@ -158,6 +204,19 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeZeitpunkt(e.target.value)}
/>
</Feld>
<Feld label="Kraftstoff">
<select
className="dm-auswahl"
value={kraftstoff}
onChange={(e) => setzeKraftstoff(e.target.value)}
>
{KRAFTSTOFFSORTEN.map((sorte) => (
<option key={sorte} value={sorte}>
{sorte}
</option>
))}
</select>
</Feld>
<Feld label="Menge" unit="l">
<input
className="dm-eingabe"
@@ -176,6 +235,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeKosten(e.target.value)}
/>
</Feld>
{smartdealAktiv && (
<Feld label="SmartDeal-Ersparnis" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={ersparnis}
onChange={(e) => setzeErsparnis(e.target.value)}
/>
</Feld>
)}
<Feld label="Tankstelle">
<input
className="dm-eingabe"
+16 -20
View File
@@ -1,8 +1,10 @@
/**
* 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).
* mit eigener Detailseite. Vorlage: vTuerenKlappen() im Panel - deckungsgleiche
* dt/dd-Zeile (Label links in var(--fg), Punkt+Zustand rechts in var(--fg2)),
* nicht die Kreis-Häkchen aus Fahrzeugstatus.tsx: die Detailliste verwendet
* bewusst den einfachen Punkt wie jede andere Wertzeile im Panel.
*/
import { Tile } from "@audi-dash/ui"
@@ -27,26 +29,20 @@ export function TuerenKlappen() {
return (
<Tile>
<span className="ads-eyebrow">Türen und Klappen</span>
<ul className="dm-pruefliste">
<div style={{ marginTop: 10 }}>
{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>
<div className="dm-wertzeile" key={punkt.label}>
<dt>{punkt.label}</dt>
<dd style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
<span
className={`ads-dot ads-dot--${punkt.ok === null ? "warn" : punkt.ok ? "ok" : "bad"}`}
aria-hidden="true"
/>
{punkt.ok === null ? "unbekannt" : punkt.ok ? "zu" : "offen"}
</dd>
</div>
))}
</ul>
</div>
</Tile>
)
}
+72 -51
View File
@@ -17,6 +17,7 @@ import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel } from "./bausteine"
import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service"
import { StandortVorschau } from "./Standort"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import { Typenschild, schildErkennen } from "./Typenschild"
@@ -64,12 +65,14 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
const sicherheitsstatus =
fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"
// Wortlaut deckungsgleich mit vHome() im Panel (g === null ? "Status unbekannt" :
// g ? "Sicher abgestellt" : "Nicht sicher abgestellt").
const sicherheitstext =
fahrzeug.gesichert === true
? "Sicher abgestellt"
: fahrzeug.gesichert === false
? "Nicht vollständig verschlossen"
: "Zustand unbekannt"
? "Nicht sicher abgestellt"
: "Status unbekannt"
const aktualisieren = async () => {
setzeAktualisiert(true)
@@ -93,14 +96,19 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
Schild tritt der volle Name an dessen Stelle. */}
<div className="ads-title dm-szene__zusatz">
Schild tritt der volle Name an dessen Stelle. Deckungsgleich
mit ".badge .zusatz" im Panel: 13px, var(--fg2), nicht kursiv -
der Umschließer trug bis 2026-08-30 fälschlich die Klasse
"ads-title" (22px/fett wirkend), und .ads-sport (kursiv, für
andere Stellen gedacht) passte hier ebenfalls nicht
(Parität-Audit). */}
<div className="dm-szene__zusatz">
{schild ? (
zusatz && <span className="ads-sport">{zusatz}</span>
zusatz && <span>{zusatz}</span>
) : (
<>
{einstellungen.fahrzeugtitel}{" "}
{zusatz && <span className="ads-sport">{zusatz}</span>}
{zusatz && <span>{zusatz}</span>}
</>
)}
</div>
@@ -118,17 +126,27 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
onClick={() => geheZu("sicherheit")}
/>
<Tile>
<span className="ads-eyebrow">Reichweite</span>
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<div className="dm-tanktext">
<span>{de(fahrzeug.tankPct)} % im Tank</span>
<span>
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
{de(einstellungen.tankvolumen)} l
{/* Reihenfolge deckungsgleich mit vHome() im Panel (".tankzeile", eigens
als myAudi-App-Konvention kommentiert, die einzige Kachel im ganzen
Panel mit der Beschriftung UNTEN statt oben): Zahl und Prozent in
einer Zeile (unten ausgerichtet), dann der Balken, erst dann das
Label "Reichweite". 60px-Hauptzahl (nicht 40 - das ist die Größe
des kleineren "Nächster Service"-Werts), reine Prozentzahl ohne
Zusatztext. Die frühere Liter-Schätzzeile ("rund X von Y l") wurde
am 2026-08-16 aus dem Panel entfernt (Nutzerwunsch) und war hier nie
nachgezogen worden. Panel: "tile flat" - keine Kartenfläche, war
hier fälschlich eine normale (graue) Tile (Parität-Audit). */}
<Tile variant="flat">
<div className="dm-tankzeile">
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={60} />
<span className="dm-tankzeile__pct">
{fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : "—"}
</span>
</div>
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
Reichweite
</span>
</Tile>
{/* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich
@@ -142,49 +160,52 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
Anzeige. Deckungsgleich mit vHome() im Panel. */}
<Tile>
<button
type="button"
className="dm-serviceblock"
onClick={() => geheZu("service")}
>
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-serviceblock__chevron" aria-hidden="true">
</span>
{service ? (
<>
{(() => {
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 ? `voraussichtlich am ${datum(service.datum)}` : service.art}
</span>
</>
) : (
{/* Ohne Termin ist dieser Teil im Panel bewusst kein Knopf (kein
data-go, kein Chevron) - es gibt nichts, wohin er führen könnte.
"differentiate controls from content" gilt auch für die
Abwesenheit von Inhalt. */}
{service ? (
<button type="button" className="dm-serviceblock" onClick={() => geheZu("service")}>
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-serviceblock__chevron" aria-hidden="true">
</span>
{(() => {
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 ? `voraussichtlich am ${datum(service.datum)}` : service.art}
</span>
</button>
) : (
<div className="dm-serviceblock">
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-fussnote">kein Eintrag im Wartungsplan</span>
)}
</button>
</div>
)}
<div className="dm-servicezeile">
<span className="dm-servicezeile__k">Kilometerstand</span>
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} />
</div>
</Tile>
<StandortVorschau geheZu={() => geheZu("standort")} />
<div className="dm-raster">
{letzteFahrt ? (
<NaviKachel
+89 -32
View File
@@ -294,19 +294,35 @@ export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: st
)
}
interface SchutzbriefEntweder {
bez?: string
grenze?: string
zusatz?: string
}
interface SchutzbriefGruppe {
titel?: string
posten?: [string, string, string?][]
}
export function Schutzbrief() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
const listen: [string, string[]][] = [
["Auslöser", (s["ausloeser"] ?? []) as string[]],
["Entweder", (s["entweder"] ?? []) as string[]],
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]],
["Nicht versichert", (s["nicht"] ?? []) as string[]],
["Pflichten", (s["pflichten"] ?? []) as string[]],
]
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
const ausloeser = (s["ausloeser"] ?? []) as string[]
const entweder = (s["entweder"] ?? []) as SchutzbriefEntweder[]
const gruppen = (s["gruppen"] ?? []) as SchutzbriefGruppe[]
// [Bezeichnung, Begründung] - dieselbe Zwei-Spalten-Form wie im Panel
// (sb.nicht.map(([bez, begr]) => ...)), nicht ein Array aus Strings.
const nicht = (s["nicht"] ?? []) as [string, string][]
const pflichten = (s["pflichten"] ?? []) as string[]
const hatInhalt =
ausloeser.length > 0 ||
entweder.length > 0 ||
gruppen.length > 0 ||
nicht.length > 0 ||
pflichten.length > 0
if (!hatInhalt) {
return (
@@ -319,30 +335,71 @@ export function Schutzbrief() {
return (
<>
<Tile>
<span className="ads-eyebrow">Schutzbrief</span>
<Werteliste
kinder={
<>
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} />
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} />
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} />
</>
}
/>
</Tile>
{listen
.filter(([, eintraege]) => eintraege.length > 0)
.map(([titel, eintraege]) => (
<Tile key={titel}>
<span className="ads-eyebrow">{titel}</span>
<ul className="dm-liste">
{eintraege.map((eintrag) => (
<li key={eintrag}>{eintrag}</li>
))}
</ul>
</Tile>
))}
{entweder.length > 0 && (
<Tile>
<span className="ads-eyebrow">Die Entscheidung am Schadenort</span>
<p className="dm-fussnote">
Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf
die anderen beiden.
</p>
<Werteliste
kinder={entweder.map((x, i) => (
<Wertzeile key={x.bez ?? i} label={x.bez ?? ""} wert={x.grenze ?? ""} zusatz={x.zusatz} />
))}
/>
<p className="dm-fussnote">Mobilität wird binnen 60 Minuten organisiert.</p>
</Tile>
)}
{ausloeser.length > 0 && (
<Tile>
<span className="ads-eyebrow">Wann der Schutzbrief greift</span>
<ul className="dm-liste">
{ausloeser.map((a) => (
<li key={a}>{a}</li>
))}
</ul>
{Boolean(s["gilt"]) && <p className="dm-fussnote">Gilt in {s["gilt"] as string}</p>}
{Boolean(s["versichert"]) && (
<p className="dm-fussnote">Versichert: {s["versichert"] as string}</p>
)}
</Tile>
)}
{gruppen.map((g, gi) => (
<Tile key={g.titel ?? gi}>
<span className="ads-eyebrow">{g.titel}</span>
<Werteliste
kinder={(g.posten ?? []).map(([bez, wert, zusatz], i) => (
<Wertzeile key={bez ?? i} label={bez} wert={wert} zusatz={zusatz} />
))}
/>
</Tile>
))}
{nicht.length > 0 && (
<Tile>
<span className="ads-eyebrow">Nicht versichert</span>
<Werteliste
kinder={nicht.map(([bez, begruendung], i) => (
<Wertzeile key={bez ?? i} label={bez} wert="" zusatz={begruendung} />
))}
/>
</Tile>
)}
{pflichten.length > 0 && (
<Tile>
<span className="ads-eyebrow">Deine Pflichten</span>
<ul className="dm-liste">
{pflichten.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
{Boolean(s["rang"]) && <p className="dm-fussnote">{s["rang"] as string}</p>}
{Boolean(s["stand"]) && <p className="dm-fussnote">{s["stand"] as string}</p>}
</Tile>
)}
</>
)
}
+5
View File
@@ -9,6 +9,11 @@ import { ActionButton, Tile } from "@audi-dash/ui"
import type { SicherheitsPunkt } from "../api"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
(Bearbeiten). */
export const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"] as const
/**
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
* Hinweis, was als Nächstes passiert nie nur keine Daten".
+15
View File
@@ -29,6 +29,7 @@ export const WEITERE_BILDER = [
"seitenansicht-winter.webp",
"rad-sommer.webp",
"rad-winter.webp",
"draufsicht.webp",
] as const
export const ERLAUBTE_DATEINAMEN: readonly string[] = [
@@ -47,3 +48,17 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}`
}
/**
* Feste Datei des Kraftstoff-Marken-Logos für die SmartDeal-Anzeige -
* Bestandteil des Integrations-Bündels
* (`custom_components/audi_dashboard/frontend/bilder/shell-logo.svg`), unter
* dem eigenen Statik-Pfad der Integration ausgeliefert (`/audi_dashboard_static/`)
* - anders als die Fahrzeugfotos NICHT unter `/local/bilder/`. Deckungsgleich
* mit `MARKENLOGO_DATEI`/`STATIK` im Panel.
*/
export function markenlogoUrl(): string | undefined {
const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined
return `${basis}/audi_dashboard_static/bilder/shell-logo.svg`
}
+3
View File
@@ -16,6 +16,7 @@ import { LiveFahrt } from "./LiveFahrt"
import { MeinAudi } from "./MeinAudi"
import { Reifen } from "./Reifen"
import { Service, Servicebuch, Werkstatt } from "./Service"
import { Standort } from "./Standort"
import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken"
@@ -65,6 +66,8 @@ export function SeiteFuer(props: SeitenProps) {
return <Fahrzeugstatus geheZu={geheZu} />
case "tuerenklappen":
return <TuerenKlappen />
case "standort":
return <Standort />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "ident":
+1 -1
View File
@@ -118,7 +118,7 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
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())
await waitFor(() => expect(container.querySelector(".dm-wertzeile")).not.toBeNull())
expect(screen.getByText("Motorhaube")).toBeTruthy()
expect(screen.queryByText("Standlicht")).toBeNull()
})
+28
View File
@@ -61,3 +61,31 @@
font-family: "Audi Type Extended", var(--font-stack);
font-style: italic;
}
/* iOS-Auflage vom 2026-08-13 ("iOS-Look wurde offiziell", AGENTS.md Abschnitt
C): das Panel ersetzte die alte, großgeschriebene Audi-CI-Eyebrow projektweit
durch einen normalen, kleinen grauen Kurztext (".label" in
audi-dashboard-ios.css, live gemessen: 13px, text-transform:none,
letter-spacing:.01em - keine Großschreibung, kaum Laufweite). design-system
selbst bleibt bei der ursprünglichen, markenneutralen Eyebrow-Optik (dient
auch Claude Design als eigenständiger Kit) - companion-app überschreibt hier
lokal, damit sie exakt wie das Panel aussieht (Owner-Entscheidung
2026-08-29: "die iOS App soll optisch möglichst identisch zur vorhandenen
HA App sein"). */
.ads-eyebrow {
font-size: 13px;
letter-spacing: .01em;
text-transform: none;
font-weight: 400;
color: var(--fg3);
}
/* .quad .l im Panel (Statistik-Kacheln): 11px, keine Großschreibung, normale
Laufweite - @audi-dash/ui's neutrales .ads-quad__l (9px, uppercase,
letter-spacing .12em) ist derselbe Vor-iOS-Eyebrow-Stil wie .ads-eyebrow
oben, hier lokal auf den live gemessenen Panel-Wert korrigiert. */
.ads-quad__l {
font-size: 11px;
letter-spacing: normal;
text-transform: none;
}
+4 -3
View File
@@ -47,9 +47,10 @@
color: var(--fg);
}
.dm-kopf__aktion[aria-current="page"] {
color: var(--red);
}
/* Kein rotes Aktiv-Signal hier: Rot bleibt Akzent/destruktiv, keine
Navigationsfarbe (dieselbe Regel wie beim Panel-Zurückpfeil, den primären
Aktionsknöpfen und der Arbeitsweg-Pille - siehe AGENTS.md 2026-08-13/17).
Der Ringe-Knopf selbst hat im Panel auch kein eigenes Aktiv-Aussehen. */
/* ---------------------------------------------------------- Inhaltsfläche */
+523 -73
View File
@@ -234,11 +234,23 @@ input[type="date"].dm-eingabe {
/* ------------------------------------------------------------ Übersicht */
/* Deckungsgleich mit .szene.bleed im Panel: randlos, keine Kartenfläche -
Fahrzeugbild und Name/Kennzeichen sitzen direkt auf dem Seitenhintergrund,
nicht in einer grauen Karte. War hier bis 2026-08-30 fälschlich als
Kachel mit Hintergrund gebaut (Parität-Audit). */
.dm-szene {
position: relative;
border-radius: var(--r-tile);
overflow: hidden;
background: var(--tile);
}
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
nicht das 4:3-Seitenverhältnis, das ImagePlaceholder sonst für Galerien
verwendet (dort korrekt, hier war es das nie - "carfix klein" im Panel ist
16:8). Scoped auf die Übersicht-Szene, damit andere ImagePlaceholder-
Stellen (Mein Audi, Reifen, ...) ihre eigene, richtige Größe behalten. */
.dm-szene .ads-bildbox {
aspect-ratio: 16 / 8;
}
.dm-szene .ads-bild,
@@ -247,8 +259,14 @@ input[type="date"].dm-eingabe {
display: block;
}
/* Kein linkes/rechtes Innenpolster: das Panel stellt Kennzeichen/Marke in
eine randlose "tile flat" (padding:0) - links/rechts eingerückt zu haben
ließ diesen Text nicht mit "Reichweite" & Co. auf einer Kante stehen
(Nutzerbericht "nicht linksbündig zum Rest der Seite", live gemessen:
38px hier gegen 22px bei "Reichweite" - im Panel beide bei 16px). Nur
oben/unten bleibt Abstand (zum Bild bzw. zur nächsten Kachel). */
.dm-szene__text {
padding: var(--sp-4) var(--sp-4) var(--sp-5);
padding: var(--sp-4) 0 var(--sp-5);
}
/* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel,
@@ -267,9 +285,15 @@ input[type="date"].dm-eingabe {
font: inherit;
text-align: left;
color: inherit;
}
/* Nur wenn es wirklich ein Knopf ist (ein Termin vorhanden, siehe
Uebersicht.tsx) - der "kein Eintrag"-Zustand ist ein reines <div>, ohne
Zeigehand/Press-Feedback, deckungsgleich mit der nicht-klickbaren
.tile-Variante im Panel. */
button.dm-serviceblock {
cursor: pointer;
}
.dm-serviceblock:active {
button.dm-serviceblock:active {
opacity: 0.55;
}
.dm-serviceblock__chevron {
@@ -282,10 +306,19 @@ input[type="date"].dm-eingabe {
}
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */
/* margin-top deckungsgleich mit .fig-zeile im Panel - fehlte hier, "Nächster
Service:" und die Kennzahl standen ohne Abstand aufeinander (Parität-
Audit 2026-08-30). Der zweite Selektor deckt den Fall ohne Serviceart-
Icon ab (Hauptuntersuchung), wo das Panel dasselbe margin-top direkt auf
.fig setzt statt auf eine Wrapper-Zeile. */
.dm-servicefig {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.dm-serviceblock > .ads-fig {
margin-top: 8px;
}
.dm-servicefig svg {
color: var(--fg2);
@@ -339,6 +372,9 @@ input[type="date"].dm-eingabe {
margin-top: 8px;
}
.dm-tgwert {
/* Fehlte hier ganz - fiel auf die schmale Grundschrift zurück statt auf
Audi Type Wide wie .tg-wert im Panel (Parität-Audit 2026-08-29). */
font-family: var(--font-wide);
font-weight: 300;
font-size: 22px;
color: var(--fg);
@@ -357,14 +393,19 @@ input[type="date"].dm-eingabe {
margin-top: 6px;
}
.dm-tanktext {
/* Reichweiten-Kachel auf der Übersicht - deckungsgleich mit .tankzeile/.pct
im Panel (audi-dashboard.css). */
.dm-tankzeile {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-2);
font-size: 12.5px;
font-weight: 300;
gap: 14px;
margin-bottom: 14px;
}
.dm-tankzeile__pct {
font-size: 15px;
color: var(--fg2);
padding-bottom: 6px;
}
.dm-navikachel__wert {
@@ -374,16 +415,27 @@ input[type="date"].dm-eingabe {
color: var(--fg);
}
/* "Letzte Fahrt"/"Letzte Tankung" auf der Übersicht - deckungsgleich mit
.leaf .v/.leaf .v small im Panel (15px/400/var(--fg2), Zusatzzeile
12.5px/var(--fg3), KEINE Wide-Schrift/Hero-Zahl). War hier 20px/300 ohne
Farbangabe - eine erfundene "große Zahl"-Behandlung, die dieser Kachel im
Panel nie zustand (Parität-Audit 2026-08-30). Nur hier verwendet (die
anderen NaviKachel-Aufrufe in MeinAudi.tsx/Service.tsx/Versicherung.tsx
nutzen .dm-navikachel__wert direkt, unangetastet). */
.dm-navikachel__zahl {
font-size: 20px;
font-weight: 300;
font-size: 15px;
font-weight: 400;
color: var(--fg2);
}
.dm-navikachel__wert .dm-navikachel__zahl + small {
font-size: 12.5px;
}
.dm-navikachel__wert small,
.dm-wertzeile small {
display: block;
margin-top: 2px;
font-size: 11.5px;
font-size: 12.5px;
color: var(--fg3);
}
@@ -420,12 +472,17 @@ input[type="date"].dm-eingabe {
margin: var(--sp-3) 0 0;
}
/* Live am Panel gemessen (.row/dt/dd, audi-dashboard.css): beide Spalten
16px/400, dt (Feldname) in var(--fg) - NICHT grau, das ist der Wert (dd,
var(--fg2)) - genau umgekehrt zu dem, was hier vorher stand (13px/grau
Label, 14px/hell Wert). Betrifft jede Wertzeile app-weit (Parität-Audit
2026-08-30). */
.dm-wertzeile {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
padding: 10px 0;
padding: 11px 0;
border-bottom: 1px solid var(--line);
}
@@ -434,15 +491,16 @@ input[type="date"].dm-eingabe {
}
.dm-wertzeile dt {
font-size: 13px;
font-weight: 300;
color: var(--fg2);
font-size: 16px;
font-weight: 400;
color: var(--fg);
}
.dm-wertzeile dd {
margin: 0;
font-size: 14px;
font-weight: 300;
font-size: 16px;
font-weight: 400;
color: var(--fg2);
text-align: right;
}
@@ -454,6 +512,60 @@ input[type="date"].dm-eingabe {
padding: var(--sp-3) 2px 0;
}
/* Wechselbild "Mein Audi" - deckungsgleich mit carImg("galerie")/.dots im
Panel: ein antippbares Bild, das zum nächsten Galerieplatz weiterschaltet,
plus Punktanzeige darunter. */
.dm-galerie-wechsel {
display: block;
width: 100%;
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
/* 16:9, wie carImg("galerie") im Panel ("carfix", nicht "carfix klein" -
das 16:8-Maß gilt nur für die Übersicht-Szene). */
.dm-galerie-wechsel .ads-bildbox {
aspect-ratio: 16 / 9;
}
.dm-galerie-punkte {
display: flex;
gap: 6px;
justify-content: center;
margin-top: 12px;
}
.dm-galerie-punkte span {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--line-strong);
}
.dm-galerie-punkte span.an {
background: var(--red);
}
/* Klickbare Wertzeile (Batteriespannung Verlauf) - deckungsgleich mit
`.row[data-go]` im Panel: derselbe Zeilenaufbau wie .dm-wertzeile, nur als
<button> statt <div>, mit Chevron in der Wertespalte. */
.dm-wertzeile--knopf {
width: 100%;
border: 0;
background: none;
font: inherit;
text-align: inherit;
color: inherit;
cursor: pointer;
}
.dm-wertzeile--knopf dd {
display: flex;
align-items: center;
gap: 6px;
}
.dm-wertzeile__chevron {
color: var(--fg3);
font-size: 13px;
}
.dm-fussnote {
margin: var(--sp-3) 0 0;
font-size: 11.5px;
@@ -465,60 +577,12 @@ input[type="date"].dm-eingabe {
/* ------------------------------------------------------ Fahrzeugstatus */
.dm-pruefliste {
list-style: none;
margin: var(--sp-3) 0 0;
padding: 0;
}
.dm-pruefliste__zeile {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.dm-pruefliste__zeile:last-child {
border-bottom: 0;
}
.dm-pruefliste__label {
flex: 1;
font-size: 14px;
font-weight: 300;
}
.dm-pruefliste__wert {
font-size: 12.5px;
font-weight: 300;
color: var(--fg2);
}
.dm-punkt {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
}
.dm-punkt--ok {
background: var(--ok);
}
.dm-punkt--offen {
background: var(--bad);
}
/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot,
damit "keine Meldung" nie wie eine Aussage aussieht. */
.dm-punkt--unbekannt {
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). */
so schon am Symbol erkennbar, nicht nur an der Farbe. Die Detailliste
"Türen und Klappen" (TuerenKlappen.tsx) verwendet stattdessen die
gewöhnliche .dm-wertzeile-Zeile mit dem einfachen @audi-dash/ui .ads-dot -
deckungsgleich mit dem Panel, dessen vTuerenKlappen() ebenfalls den
schlichten Punkt statt des Kreis-Häkchens zeigt. */
.dm-statuskreis {
flex: none;
width: 22px;
@@ -558,6 +622,21 @@ 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); }
/* Sammelzeile über den vier Sicherheitszeilen - deckungsgleich mit dem
obersten Kasten in vSicherheit() (style="display:flex;align-items:center;
gap:12px;padding:16px 20px"), dasselbe Kreis-Häkchen-Symbol wie die Zeilen
darunter, keine Zeitangabe. */
.ads-tile.dm-sicherheitssammel {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
}
.dm-sicherheitssammel span {
font-size: 15px;
color: var(--fg);
}
/* ------------------------------------------------------------ Galerie */
.dm-galerie {
@@ -666,6 +745,14 @@ button.dm-listenzeile {
letter-spacing: -0.02em;
}
/* Einzelfahrt/Einzelbeleg: das Panel zeichnet die Distanz- bzw. Liter-Zahl in
vTrip()/dem Tankvorgang-Detail eigens bei 52px (style="font-size:52px"),
größer als die übrigen Detailseiten mit ihrer gemeinsamen 40px-.fig - kein
Versehen, sondern die Hauptzahl der jeweiligen Seite. */
.dm-detail__zahl--gross {
font-size: 52px;
}
.dm-detail__einheit {
font-size: 15px;
color: var(--fg2);
@@ -795,6 +882,103 @@ button.dm-listenzeile {
margin-top: var(--sp-4);
}
/* Ausklappbares Korrekturfeld (Anzugsmoment/Reifenkilometer) - deckungsgleich
mit dem entsprechenden Block im Panel (margin-top:14-18px, Trennlinie
darüber). */
.dm-bearbeiten-block {
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
}
/* Reifen-Kachel: Kopfzeile aus Überschrift+km links, Radfoto rechts, beide
oben bündig - deckungsgleich mit .radkopf-row/.radkopf/.tile-headline im
Panel. */
.dm-radkopf-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-4);
}
.dm-radkopf {
display: flex;
flex-direction: column;
}
.dm-tile-headline {
font-size: 20px;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--fg);
display: block;
}
.dm-reifenzeile {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.dm-reifenzeile .ads-icon-button {
width: 24px;
height: 24px;
color: var(--fg3);
}
.dm-reifenzeile .ads-icon-button svg {
width: 16px;
height: 16px;
}
.dm-radfoto .ads-bildbox {
width: 96px;
height: 96px;
aspect-ratio: 1 / 1;
}
/* Kompakte Pille (Montiert) statt voller Breite - deckungsgleich mit
.montiert/.montiert.on im Panel. */
.dm-montiert-reihe {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--sp-4);
}
.dm-montiert {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: normal;
color: var(--fg2);
background: none;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
padding: 7px 13px;
cursor: pointer;
}
.dm-montiert::before {
content: "";
position: absolute;
top: -8px;
bottom: -8px;
left: 0;
right: 0;
}
.dm-montiert:active {
transform: scale(0.96);
}
.dm-montiert--an {
background: var(--fg);
border-color: var(--fg);
color: var(--canvas);
}
.dm-archivbtn {
background: var(--tile-2);
color: var(--fg2);
}
.dm-archivbtn:hover,
.dm-archivbtn:active {
color: var(--fg);
}
.dm-waehlen {
display: flex;
flex-wrap: wrap;
@@ -836,14 +1020,18 @@ button.dm-listenzeile {
}
/* Der Zusatz neben dem Typenschild darf umbrechen abgeschnittener Text mit
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. Größe/Farbe
deckungsgleich mit ".badge .zusatz" im Panel (13px, var(--fg2), nicht
kursiv) - war hier 19px, geerbt von der (falschen) "ads-title"-Klasse am
Umschließer (Parität-Audit 2026-08-30). */
.dm-szene__zusatz {
min-width: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
font-size: 19px;
line-height: 1.2;
font-size: 13px;
line-height: normal;
color: var(--fg2);
}
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
@@ -854,3 +1042,265 @@ button.dm-listenzeile {
justify-content: space-between;
gap: var(--sp-3);
}
/* ---------------------------------------------------------------- Standort
Live-GPS-Position des Fahrzeugs deckungsgleich mit .standort-kachel/
.standort-vollbild/.standortmenu im Panel (audi-dashboard.css). War bis
2026-08-29 komplett unportiert (Parität-Audit). */
.dm-standort-kachelbtn {
display: block;
width: 100%;
position: relative;
border: none;
padding: 0;
margin: 0 0 var(--sp-3);
background: none;
cursor: pointer;
border-radius: var(--r-tile);
overflow: hidden;
}
.dm-standort-vorschau {
width: 100%;
height: 190px;
background: var(--tile);
}
.dm-standort-leer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--fg3);
text-align: center;
padding: 0 30px;
background: var(--tile);
}
.dm-fahrzeug-pin-wrap,
.dm-user-pin-wrap {
background: none;
border: none;
}
.dm-fahrzeug-pin {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
}
.dm-user-pin {
position: relative;
width: 16px;
height: 16px;
border-radius: 50%;
background: #0a84ff;
border: 3px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.dm-user-pin::after {
content: "";
position: absolute;
inset: -14px;
border-radius: 50%;
background: rgba(10, 132, 255, 0.18);
animation: dm-user-pulse 2.4s ease-out infinite;
}
@keyframes dm-user-pulse {
0% {
transform: scale(0.4);
opacity: 0.9;
}
100% {
transform: scale(1);
opacity: 0;
}
}
/* Füllt die verfügbare Höhe der Seite, statt bei 640px zu kappen und darunter
leeren, mit dem geschlossenen Blatt verwechselbaren Seitenhintergrund
stehen zu lassen (Nutzerbericht "Standort Slide Up ist leer" - der Inhalt
fehlte nicht, er saß nur unterhalb der Kappungsgrenze in leerem Weiß).
`.dm-inhalt > *{flex:none}` (rahmen.css) gilt für jede andere Seite zu
Recht - hier gezielt aufgehoben, die einzige Seite ohne weiteren Inhalt
unter einer einzelnen großen Fläche. */
.dm-inhalt > .dm-standort-vollbild {
flex: 1 1 auto;
}
.dm-standort-vollbild {
position: relative;
min-height: 400px;
border-radius: var(--r-tile);
overflow: hidden;
margin: 0 calc(-1 * var(--sp-5));
}
.dm-standort-karte {
width: 100%;
height: 100%;
}
.dm-kartensteuerung {
position: absolute;
z-index: 400;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.dm-kartensteuerung button {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 20, 26, 0.84);
color: #fff;
border: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
cursor: pointer;
padding: 0;
}
[data-theme="tag"] .dm-kartensteuerung button {
background: rgba(255, 255, 255, 0.92);
color: #101418;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
}
.dm-kartensteuerung button:active {
transform: scale(0.94);
}
.dm-standortmenu {
position: absolute;
z-index: 410;
left: 0;
right: 0;
bottom: 0;
--dm-standort-peek: 96px;
background: var(--canvas);
border-radius: var(--r-tile) var(--r-tile) 0 0;
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.35);
padding: 0 20px max(18px, env(safe-area-inset-bottom));
transform: translateY(calc(100% - var(--dm-standort-peek)));
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.dm-standortmenu--offen {
transform: translateY(0);
}
.dm-standortmenu__griffzone {
padding: 10px 0 14px;
position: relative;
}
.dm-standortmenu__griff {
position: relative;
display: block;
width: 36px;
height: 4px;
border: 0;
padding: 0;
border-radius: 2px;
background: var(--line-strong);
margin: 0 auto;
cursor: pointer;
}
.dm-standortmenu__schliessen {
position: absolute;
top: 14px;
right: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--tile);
border: none;
color: var(--fg2);
cursor: pointer;
padding: 0;
}
.dm-standortmenu__kopf {
margin-top: 8px;
padding-right: 44px;
}
.dm-standortmenu__name {
font-size: 17px;
color: var(--fg);
}
.dm-standortmenu__distanz {
font-size: 12.5px;
color: var(--fg3);
margin-top: 4px;
}
.dm-standortmenu__adresse {
font-size: 13.5px;
color: var(--fg2);
margin-top: 20px;
}
.dm-standortmenu__status {
display: flex;
align-items: center;
gap: 9px;
margin-top: 8px;
font-size: 13.5px;
color: var(--fg2);
}
.dm-standortmenu__status svg {
color: var(--fg3);
flex: none;
}
.dm-standortmenu__punkt {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
flex: none;
}
.dm-standortmenu__werte {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 13.5px;
color: var(--fg2);
}
.dm-standortmenu__werte svg {
color: var(--fg3);
flex: none;
}
.dm-standortmenu__aktionen {
display: flex;
gap: 10px;
margin-top: 18px;
padding-bottom: 4px;
}
.dm-standort-pille {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 20px;
border-radius: var(--r-pill);
border: 1px solid var(--line-strong);
background: none;
color: var(--fg);
font-size: 14px;
text-decoration: none;
cursor: pointer;
}
.dm-standort-pille--primaer {
background: var(--fg);
border-color: var(--fg);
color: var(--canvas);
}
.dm-standort-pille:active {
transform: scale(0.97);
}
.dm-standort-pille:disabled {
opacity: 0.4;
pointer-events: none;
}
+25 -1
View File
@@ -54,11 +54,18 @@ export function SymbolUebersicht({ groesse }: SymbolProps) {
)
}
// Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz),
// dasselbe Pfad wie ICONS.audi im Panel. Fläche statt Kontur (voll), wie dort
// (siehe ICONS_VOLL). War hier bis 2026-08-29 ein grobes Platzhalter-Viereck -
// nie durch das echte Symbol ersetzt worden (gefunden beim Parität-Audit).
export function SymbolAudi({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />}
voll
kinder={
<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z" />
}
/>
)
}
@@ -238,6 +245,23 @@ export function SymbolInspektion({ groesse }: SymbolProps) {
)
}
// edit-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim
// übernommen wie oil-change-s/inspection-s oben) - für den km-Korrektur-
// Stift bei Anzugsmoment/Reifenkilometern (Vorlage: CI.editS im Panel).
export function SymbolEdit({ groesse }: SymbolProps) {
return (
<RahmenFlaeche
groesse={groesse}
kinder={
<path
fill="currentColor"
d="m14.14 1.06 2.4 1.1.04.02c.23.16.42.39.52.65.1.27.13.6-.06.91l-.91 1.94 2.94 1.38-2.82 5.95-.9-.42 2.38-5.05-2.02-.95L9.5 19.8 5 23.57v-5.88l7.56-16.14.02-.03c.16-.23.39-.42.65-.52a1 1 0 0 1 .9.06Zm1.08 4.2.94-2 .02-.03v-.04a.4.4 0 0 0-.13-.16l-2.4-1.1-.02-.01h-.04a.4.4 0 0 0-.16.13L12.53 4l2.7 1.27Zm-3.12-.37L6.16 17.56l2.7 1.27L14.8 6.16 12.1 4.9ZM6 18.6v2.84l2.18-1.81L6 18.59Z"
/>
}
/>
)
}
export function SymbolWarnung({ groesse }: SymbolProps) {
return (
<Rahmen
+14 -1
View File
@@ -37,7 +37,20 @@ export default defineConfig({
base: "./",
plugins: [react()],
define: { __APP_VERSION__: JSON.stringify(appVersion) },
server: { port: 5173 },
// Nur fuer `vite dev` (server.* wirkt nie in `vite build`, also risikolos
// fuer die echte App). Der Browser darf `/api/...` gegen den eigenen
// Vite-Ursprung aufrufen - das ist same-origin, keine Praeflight-Anfrage
// noetig - und Vite selbst (ein Node-Prozess, keine Browser-CORS-Regeln)
// reicht das serverseitig an die HA-Testinstanz weiter. Sonst blockt HA
// die Anfrage direkt (siehe AGENTS.md: "CORS ist ein echter Zwang fuer
// diese App" - `cors_allowed_origins` wirkt seit HA 2026.8 nicht). Nur
// gegen `audi_ha_test` (:18123) - die echte Instanz wird nie beruehrt.
server: {
port: 5173,
proxy: {
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true },
},
},
build: {
outDir: "dist",
target: "es2022",