From e1ebe6d694465d490cad7289e08cab949de79223 Mon Sep 17 00:00:00 2001 From: Tobi G Date: Sun, 30 Aug 2026 12:21:58 +0200 Subject: [PATCH] =?UTF-8?q?Vollst=C3=A4ndiger=20Panel-companion-app-Parit?= =?UTF-8?q?=C3=A4tsaudit=20(11=20Kapitel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/launch.json | 11 + AGENTS.md | 798 +++++++++++++++++- companion-app/scripts/ios-signieren.sh | 22 + companion-app/src/Shell.test.tsx | 2 +- companion-app/src/Shell.tsx | 10 +- companion-app/src/api/index.ts | 14 + companion-app/src/daten/DatenKontext.tsx | 8 + companion-app/src/daten/profilAdapter.ts | 2 + companion-app/src/daten/service.test.ts | 1 + companion-app/src/navigation.ts | 3 + companion-app/src/screens/Batterie.tsx | 47 +- companion-app/src/screens/Einstellungen.tsx | 141 +++- companion-app/src/screens/FahrtDetail.tsx | 2 +- companion-app/src/screens/Fahrten.tsx | 14 +- companion-app/src/screens/Fahrzeugstatus.tsx | 37 +- companion-app/src/screens/Karte.tsx | 16 +- companion-app/src/screens/MeinAudi.tsx | 225 ++++- companion-app/src/screens/Reifen.tsx | 372 +++++--- companion-app/src/screens/Standort.tsx | 555 ++++++++++++ companion-app/src/screens/TankDetail.tsx | 182 +++- companion-app/src/screens/Tanken.tsx | 86 +- companion-app/src/screens/TuerenKlappen.tsx | 36 +- companion-app/src/screens/Uebersicht.tsx | 123 +-- companion-app/src/screens/Versicherung.tsx | 121 ++- companion-app/src/screens/bausteine.tsx | 5 + companion-app/src/screens/bilder.ts | 15 + companion-app/src/screens/register.tsx | 3 + companion-app/src/screens/screens.test.tsx | 2 +- companion-app/src/stile/audi-schrift.css | 28 + companion-app/src/stile/rahmen.css | 7 +- companion-app/src/stile/screens.css | 596 +++++++++++-- companion-app/src/symbole.tsx | 26 +- companion-app/vite.config.ts | 15 +- .../audi_dashboard/frontend/app/bundle.json | 2 +- .../audi_dashboard/frontend/app/bundle.zip | Bin 236046 -> 248241 bytes .../frontend/audi-dashboard-app.js | 3 +- .../audi_dashboard/manifest.json | 2 +- design-system/src/components/Feld/Feld.css | 54 +- design-system/src/components/Fig/Fig.css | 7 +- design-system/src/components/Seg/Seg.css | 27 +- .../src/components/Switch/Switch.css | 4 +- .../src/components/TabBar/TabBar.css | 15 +- design-system/src/tokens/tokens.css | 68 +- 43 files changed, 3256 insertions(+), 451 deletions(-) create mode 100644 .claude/launch.json create mode 100644 companion-app/src/screens/Standort.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c717475 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "companion-app", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "companion-app"], + "port": 5173 + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index f408583..b7f3e22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,9 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-08-29** (iOS signing prepared end to end — device build verified, signature +**Last updated: 2026-08-30** (a full 11-chapter panel↔companion-app visual/functional parity audit, +one continuous session — see "Chapter 1" through "Chapter 11 (Abschluss)" further down for the +complete record; companion-app manifest bumped to `2026.8.30.2`, OTA bundle rebuilt and redeployed; +2026-08-29: iOS signing prepared end to end — device build verified, signature blocked on a device registration at Apple, see section AI; 2026-08-23: merged the `umsetzung-datametric360` branch — companion app phases 1–10 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed @@ -4830,6 +4833,799 @@ button never renders. The manifest is unaffected — the iOS installer parses it of content type — so the working route is to paste the `itms-services://` URL into Safari's address bar directly. The script prints that URL for exactly this reason. +**Gap found and closed the same day (2026-08-29, asked directly: "hast du sichergestellt dass Paul +mit einem neuen Xcode-Durchlauf auch die garantiert aktuelle Version erwischt?"):** the script built +cleanly from whatever was locally checked out, but never checked that against Gitea — a forgotten +`git pull` on Paul's Mac would have signed a stale commit with no warning, the exact class of silent- +staleness bug this project keeps fixing elsewhere (cache-busting, OTA-bundle-version matching, +`install.ps1`'s bundle-vs-manifest check). `ios-signieren.sh` now refuses to build unless the working +tree is clean and `HEAD` is exactly `origin/main` (fetches first, aborts with a clear message +otherwise) — added right after `cd "$APP"`, before the web build. + +--- + +## AK. Full panel↔companion-app parity audit, done in resumable chapters (started 2026-08-29) + +Owner request, after seeing an old/stale iOS build: since the HA panel is considered functionally +final, re-derive companion-app's screens from the panel's current state exactly, so Xcode only has +to build+sign an already-correct app — instead of Paul re-doing work because the two apps still +disagree somewhere. Split into 11 chapters (one per screen group) specifically so the work survives +a context/token cutoff mid-task: each chapter ends at a clean, typechecked+tested state before the +next starts, and the owner is asked after each one whether to continue now or pause. Chapters: +1 Übersicht, 2 Mein Audi/Fahrzeugdaten, 3 Service/Reifen, 4 Versicherung/Steuer, 5 Sicherheit, 6 +Fahrten, 7 Tanken, 8 Statistik, 9 Batterie, 10 Einstellungen/Datensatz, 11 Abschluss (Build, +Versions-Bump, `npm run ota`, Gesamtbericht). + +**Chapter 1 (Übersicht) — four real drifts found and fixed, all in `Uebersicht.tsx` (vs. the panel's +`vHome()`):** +1. Status-row wording didn't match: panel says "Nicht sicher abgestellt"/"Status unbekannt", + companion-app said "Nicht vollständig verschlossen"/"Zustand unbekannt" — no recorded decision + for the difference, just drift. Aligned to the panel's exact wording. +2. The Reichweite tile still showed a "rund X von Y l" liter estimate under the percentage — that + line was deliberately **removed from the panel on 2026-08-16** (owner request, see the sixth + round that day) and was never removed here. Dropped; the percentage now renders as a bare + number, matching `CAR.tankBekannt ? pct + " %" : "–"` exactly. This needed a real data-layer gap + closed too: companion-app's `Fahrzeug` type had no `tankBekannt` field at all (unlike its + existing `odoBekannt` twin) — added to `profilAdapter.ts` (`status.tankprozent !== null/undefined`, + mirroring the panel's own `koordinator.py` derivation) and to the one test fixture that + constructs a full `Fahrzeug` object (`service.test.ts`). +3. The Reichweite figure rendered at `size={40}` — the same size as the smaller "Nächster Service" + number one tile down. The panel draws it at `font-size:60px`, a genuine hero number. Fixed to + `size={60}`. +4. When there's no next service (`service` is `null`), the panel deliberately renders that block as + a plain non-interactive `
` (no chevron, no click target) — "nothing to navigate to." Companion- + app always wrapped it in a clickable ` ) diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 0123f91..e20c8ec 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -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 { + return this.warteschlange.einreihen( + DIENST_DOMAIN, + "reifen_km_setzen", + { satz, km }, + "Kilometerstand korrigieren", + ); + } + reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise { return this.warteschlange.einreihen( DIENST_DOMAIN, diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx index b553c7b..732e186 100644 --- a/companion-app/src/daten/DatenKontext.tsx +++ b/companion-app/src/daten/DatenKontext.tsx @@ -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, diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts index 839f992..fbefba3 100644 --- a/companion-app/src/daten/profilAdapter.ts +++ b/companion-app/src/daten/profilAdapter.ts @@ -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, diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index b1feb69..b779ec7 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -21,6 +21,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { odo, odoBekannt: true, tankPct: 0, + tankBekannt: true, reichweite: null, batteriespannung: null, gesichert: null, diff --git a/companion-app/src/navigation.ts b/companion-app/src/navigation.ts index e0404c2..36c1860 100644 --- a/companion-app/src/navigation.ts +++ b/companion-app/src/navigation.ts @@ -35,6 +35,7 @@ export type SeitenName = // Sonstige | "sicherheit" | "tuerenklappen" + | "standort" | "live" | "einst" @@ -68,6 +69,7 @@ export const ZURUECK: Partial> = { einst: "home", sicherheit: "home", tuerenklappen: "sicherheit", + standort: "home", live: "home", } @@ -96,6 +98,7 @@ export const TITEL: Record = { fill: "Tankvorgang", sicherheit: "Fahrzeugstatus", tuerenklappen: "Türen und Klappen", + standort: "Standort", live: "Fahrt läuft", einst: "Einstellungen", } diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx index 21edb8e..e3a748f 100644 --- a/companion-app/src/screens/Batterie.tsx +++ b/companion-app/src/screens/Batterie.tsx @@ -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): 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} > - + {werte.map((t, i) => t.min == null ? null : ( ), diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index d43543c..3c2ec3f 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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(null) const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | null>(null) + const [backupImportLaeuft, setzeBackupImportLaeuft] = useState(false) + const [backupImportFehler, setzeBackupImportFehler] = useState(null) + const backupDateiwahl = useRef(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({ - Fahrten - + {/* 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. */} + Fahrten zusammenfassen + Tankrabatt - + + {werte.markenname} { + e.currentTarget.style.display = "none" + }} + /> + {werte.markenname}-Rabatt aktiv + + } + last={!fahrzeug.smartdeal.aktiv} + > @@ -341,25 +397,31 @@ export function Einstellungen({ aria-label="Tankrabatt aktiv" /> - - - void profilSpeichern({ - fahrzeug: { - ...fahrzeug, - smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null }, - }, - }) - } - /> - -

- Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am Folgetag - schaltet er ab. -

+ {fahrzeug.smartdeal.aktiv ? ( + <> + + + void profilSpeichern({ + fahrzeug: { + ...fahrzeug, + smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null }, + }, + }) + } + /> + +

+ Das Datum gilt einschließlich — am Stichtag selbst zählt der Rabatt noch, erst am + Folgetag schaltet er ab. +

+ + ) : ( +

Ersparnis und Bildmarke werden ausgeblendet.

+ )}
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor @@ -430,7 +492,25 @@ export function Einstellungen({ Jetzt sichern Auf dieses Gerät laden + backupDateiwahl.current?.click()} + disabled={backupImportLaeuft} + > + {backupImportLaeuft ? "Lade …" : "Von diesem Gerät einspielen"} +
+ { + const datei = e.target.files?.[0] + if (datei) void backupImportieren(datei) + e.target.value = "" + }} + /> + {backupImportFehler &&

{backupImportFehler}

} @@ -485,6 +565,19 @@ export function Einstellungen({ )} + + Version + + } + /> + + Integration-Update {/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index 38d28a9..a87f18b 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -60,7 +60,7 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
-
+
{deOderStrich(fahrt.distance_km, 1)} km
diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx index b5cb97f..55afc83 100644 --- a/companion-app/src/screens/Fahrten.tsx +++ b/companion-app/src/screens/Fahrten.tsx @@ -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 - {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "} + {strecke(fahrt)} · {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "} {dauer(fahrt.duration_s)} diff --git a/companion-app/src/screens/Fahrzeugstatus.tsx b/companion-app/src/screens/Fahrzeugstatus.tsx index f1a6155..268fd31 100644 --- a/companion-app/src/screens/Fahrzeugstatus.tsx +++ b/companion-app/src/screens/Fahrzeugstatus.tsx @@ -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 ( <> - + {/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war + hier komplett unportiert (Parität-Audit 2026-08-30). */} +
+ +
+ + {/* 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. */} + + + {sammelText} +
diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx index 69a3fad..e1c58c2 100644 --- a/companion-app/src/screens/Karte.tsx +++ b/companion-app/src/screens/Karte.tsx @@ -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) { diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx index 5fee424..cb619a4 100644 --- a/companion-app/src/screens/MeinAudi.tsx +++ b/companion-app/src/screens/MeinAudi.tsx @@ -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 ( <> - - Fotogalerie -
- {BILDPLAETZE.map((platz) => ( - - ))} + + + + +
+ +
+ {schild ? ( + zusatz && {zusatz} + ) : ( + <> + {einstellungen.fahrzeugtitel} {zusatz && {zusatz}} + + )} +
-

- Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten - Dateinamen, damit klar ist, was wohin gehört. -

+ + {einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"} +
-
- geheZu("ident")} - /> - geheZu("ident")}> + Fahrzeug + + + + + + + } - beiKlick={() => geheZu("battverlauf")} /> - geheZu("service")} /> - geheZu("reifen")} + + + + Zustand + + + + + + } /> - geheZu("vers")} + + + geheZu("service")}> + Service + + ) : ( + + ) + } /> -
+ + + geheZu("vers")}> + Versicherung/Steuer + + + + + + } + /> + + + geheZu("reifen")}> + Reifen + + + + + + } + /> + ) } diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx index 777649c..033f94d 100644 --- a/companion-app/src/screens/Reifen.tsx +++ b/companion-app/src/screens/Reifen.tsx @@ -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(null) + const [kmEntwurf, setzeKmEntwurf] = useState("") + const [wechselDatum, setzeWechselDatum] = useState(null) + const [wechselVereinbart, setzeWechselVereinbart] = useState(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 ( + +
+
+ {satzLabel} +
+ { + setzeKmEntwurf(String((satz["km"] as number) ?? "")) + setzeKmOffen((o) => (o === satzSchluessel ? null : satzSchluessel)) + }} + > + + + {satz["km"] != null ? de(satz["km"] as number) : "–"} + km +
+
+
+ +
+
+ + {kmOffen === satzSchluessel && ( +
+

+ Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem Satz zählen ab + hier weiter dazu. +

+ + setzeKmEntwurf(e.target.value)} + /> + +
+ void kmSpeichern(satzSchluessel)} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + + setzeKmOffen(null)}>Abbrechen +
+
+ )} + +
+ + void archivieren(satzSchluessel)} + disabled={laeuft} + > + + +
+ +
+ + void feldSpeichern(satzSchluessel, "marke", e.target.value)} + /> + + + void feldSpeichern(satzSchluessel, "modell", e.target.value)} + /> + + + void feldSpeichern(satzSchluessel, "mass", e.target.value)} + /> + + + void feldSpeichern(satzSchluessel, "dot", e.target.value)} + /> + + + void feldSpeichern(satzSchluessel, "druck_vorne", e.target.value)} + /> + / + void feldSpeichern(satzSchluessel, "druck_hinten", e.target.value)} + /> + + {/* 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. */} +
+ + void feldSpeichern(satzSchluessel, "kommentar", e.target.value)} + /> +
+
+
+ ) + } + return ( <> - Montiert + Anzugsmoment der Radschrauben
-
{aktiv}
- void wechseln()} disabled={laeuft}> - {laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`} - +
+ {fahrzeug.reifen.anzugsmoment != null ? de(fahrzeug.reifen.anzugsmoment) : "–"} + Nm +
+ { + setzeNmEntwurf(String(fahrzeug.reifen.anzugsmoment ?? "")) + setzeNmOffen((o) => !o) + }} + > + +
- - + + setzeNmEntwurf(e.target.value)} /> - - - } - /> - - void wechseldatumSetzen(e.target.value)} - /> - - {wechsel.datum && ( -
- - kalenderDateiLaden( - "Reifenwechsel", - wechsel.datum!, - `Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`, - ) - } - > - In den Kalender eintragen - + +
+ void anzugsmomentSpeichern()}>Speichern + setzeNmOffen(false)}>Abbrechen +
)}
- setzeGezeigt(wert as "Sommer" | "Winter")} - /> - - - {anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"} - - -
- void archivieren()} disabled={laeuft}> - - + Wechseltermin + + setzeWechselDatum(e.target.value)} + /> + + + setzeWechselVereinbart(checked)} + aria-label="Termin vereinbart" + /> + +
+ void kalenderUebernehmen()} disabled={!wechselDatumAnzeige}> + In den Kalender übernehmen +
- - - - - - - {Boolean(satz["kommentar"]) && ( - - )} - - } - /> -

- Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und - rechnet sie immer dem gerade montierten Satz zu. -

+ {satzTile("sommer")} + {satzTile("winter")} + {fahrzeug.reifen.archiv.length > 0 && ( Archiv diff --git a/companion-app/src/screens/Standort.tsx b/companion-app/src/screens/Standort.tsx new file mode 100644 index 0000000..663cedc --- /dev/null +++ b/companion-app/src/screens/Standort.tsx @@ -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 `` +} + +function fahrzeugMarkerHtml(px = 38): string { + return `
` +} + +/* 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 { + 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; 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(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 ( + + ) +} + +/** Vollbild-Standortansicht — Vorlage: vStandort() im Panel. */ +export function Standort() { + const { fahrzeug, einstellungen, fahrten } = useDaten() + const behaelter = useRef(null) + const kartenRef = useRef(null) + const fahrzeugMarkerRef = useRef(null) + const userMarkerRef = useRef(null) + const kachelRef = useRef(null) + const leafletRef = useRef(null) + + const [satellit, setzeSatellit] = useState(false) + const [eigenePosition, setzeEigenePosition] = useState(null) + const [positionsFehler, setzePositionsFehler] = useState(null) + const [adresse, setzeAdresse] = useState(null) + const [blattOffen, setzeBlattOffen] = useState(false) + const blattRef = useRef(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: `
`, + 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 ( +
+
+
+
+
+
+ +
+
{einstellungen.fahrzeugtitel}
+
{distanzAnzeige}
+
+
+
{adresseAnzeige}
+
+ {zustand.faehrt ? ( + <> + + Fahrzeug fährt + + ) : ( + <> + + + {zustand.unbekannt + ? "Fahrzustand unbekannt" + : zustand.seitTs != null + ? `Geparkt seit ${dauerSeitText(zustand.seitTs)}` + : "Fahrzeug steht"} + + + )} +
+
+ + {de(fahrzeug.tankPct)} % + {fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"} +
+
+ + + Route + + +
+
+
+ ) +} diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index f63ecd4..9c67899 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -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 setzeBearbeitenOffen(false)} /> + } + return ( <> - {datumZeit(tank.ts)} +
+ {datumZeit(tank.ts)} + +
-
+
{deOderStrich(tank.liters, 2)} l
@@ -92,6 +103,169 @@ export function TankDetail({ id }: { id: string | undefined }) { ) } +/** "YYYY-MM-DDTHH:mm" in Ortszeit für ein . */ +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(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 ( + +
+ Tankvorgang bearbeiten + +
+ + setzeZeitpunkt(e.target.value)} + /> + + + setzeKm(e.target.value)} + /> + + + setzeDistanz(e.target.value)} + /> + + + + + + setzeLiter(e.target.value)} + /> + + + setzeKosten(e.target.value)} + /> + + {smartdealAktiv && ( + + setzeErsparnis(e.target.value)} + /> + + )} + + setzeStation(e.target.value)} + /> + + {fehler && ( +

+ {fehler} +

+ )} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + function BelegBereich({ tankId, belegDatei, diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx index a22efbf..b27cdf1 100644 --- a/companion-app/src/screens/Tanken.tsx +++ b/companion-app/src/screens/Tanken.tsx @@ -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) => {tankvorgaenge.length === 0 ? "Keine Tankvorgänge" - : `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`} + : tankvorgaenge.length === 1 + ? "1 Tankvorgang" + : `${de(tankvorgaenge.length)} Tankvorgänge`}
) } diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx index 6e31f66..b8a1b18 100644 --- a/companion-app/src/screens/Uebersicht.tsx +++ b/companion-app/src/screens/Uebersicht.tsx @@ -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) {/* 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. */} -
+ 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). */} +
{schild ? ( - zusatz && {zusatz} + zusatz && {zusatz} ) : ( <> {einstellungen.fahrzeugtitel}{" "} - {zusatz && {zusatz}} + {zusatz && {zusatz}} )}
@@ -118,17 +126,27 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) onClick={() => geheZu("sicherheit")} /> - - Reichweite - - -
- {de(fahrzeug.tankPct)} % im Tank - - 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). */} + +
+ + + {fahrzeug.tankBekannt ? `${de(fahrzeug.tankPct)} %` : "—"}
+ + + Reichweite +
{/* 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. */} - + ) : ( +
+ Nächster Service: kein Eintrag im Wartungsplan - )} - +
+ )}
Kilometerstand
+ geheZu("standort")} /> +
{letzteFahrt ? ( - 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 ( <> - - Schutzbrief - - - - - - } - /> - - {listen - .filter(([, eintraege]) => eintraege.length > 0) - .map(([titel, eintraege]) => ( - - {titel} -
    - {eintraege.map((eintrag) => ( -
  • {eintrag}
  • - ))} -
-
- ))} + {entweder.length > 0 && ( + + Die Entscheidung am Schadenort +

+ Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf + die anderen beiden. +

+ ( + + ))} + /> +

Mobilität wird binnen 60 Minuten organisiert.

+
+ )} + + {ausloeser.length > 0 && ( + + Wann der Schutzbrief greift +
    + {ausloeser.map((a) => ( +
  • {a}
  • + ))} +
+ {Boolean(s["gilt"]) &&

Gilt in {s["gilt"] as string}

} + {Boolean(s["versichert"]) && ( +

Versichert: {s["versichert"] as string}

+ )} +
+ )} + + {gruppen.map((g, gi) => ( + + {g.titel} + ( + + ))} + /> + + ))} + + {nicht.length > 0 && ( + + Nicht versichert + ( + + ))} + /> + + )} + + {pflichten.length > 0 && ( + + Deine Pflichten +
    + {pflichten.map((x) => ( +
  • {x}
  • + ))} +
+ {Boolean(s["rang"]) &&

{s["rang"] as string}

} + {Boolean(s["stand"]) &&

{s["stand"] as string}

} +
+ )} ) } diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx index 882f7e4..77b4f73 100644 --- a/companion-app/src/screens/bausteine.tsx +++ b/companion-app/src/screens/bausteine.tsx @@ -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". diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts index 02316bf..2ec7dd7 100644 --- a/companion-app/src/screens/bilder.ts +++ b/companion-app/src/screens/bilder.ts @@ -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` +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index 1dcb109..dddaab4 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -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 case "tuerenklappen": return + case "standort": + return case "audi": return case "ident": diff --git a/companion-app/src/screens/screens.test.tsx b/companion-app/src/screens/screens.test.tsx index b17f9ec..c8467aa 100644 --- a/companion-app/src/screens/screens.test.tsx +++ b/companion-app/src/screens/screens.test.tsx @@ -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() }) diff --git a/companion-app/src/stile/audi-schrift.css b/companion-app/src/stile/audi-schrift.css index 05f1c8e..751454c 100644 --- a/companion-app/src/stile/audi-schrift.css +++ b/companion-app/src/stile/audi-schrift.css @@ -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; +} diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css index 9e9ca74..aa0b946 100644 --- a/companion-app/src/stile/rahmen.css +++ b/companion-app/src/stile/rahmen.css @@ -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 */ diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index e6c27ab..a6b0ce6 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -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
, 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 +