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 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 12:21:58 +02:00
parent 4fa5d4bc2d
commit e1ebe6d694
43 changed files with 3256 additions and 451 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "companion-app",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "companion-app"],
"port": 5173
}
]
}
+797 -1
View File
@@ -1,6 +1,9 @@
# AGENTS.md — Project state, review findings, open items, and working rules # 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 blocked on a device registration at Apple, see section AI; 2026-08-23: merged the
`umsetzung-datametric360` branch — companion app phases `umsetzung-datametric360` branch — companion app phases
110 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed 110 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 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. 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 `<div>` (no chevron, no click target) — "nothing to navigate to." Companion-
app always wrapped it in a clickable `<button>` with a visible chevron regardless. Fixed: the
button (with chevron) only renders when `service` exists; the empty state is a plain `<div
className="dm-serviceblock">` instead. `screens.css`'s `.dm-serviceblock` cursor/press-feedback
rules were re-scoped to `button.dm-serviceblock` so the non-interactive div doesn't get a
misleading pointer cursor.
**Checked and left alone, deliberately** (not drift, established idiom/UX choices): companion-app's
`Leerzustand` empty-state tiles for "Letzte Fahrt"/"Letzte Tankung" when no records exist yet, vs.
the panel's `teaser()` simply hiding the whole row — this is a systemic companion-app-wide UI
convention (friendly empty states instead of hidden sections), not something introduced by drift,
and changing it would touch far more than this one screen; the `naechsterService()`/`service.ts`
forecast math itself (restKm/date computation) was not re-verified line-by-line here — that belongs
to Chapter 3 (Service), where the full `termine()`/`meldungsPrognose()` family gets its own pass.
**Fifth item, found by the owner asking directly whether the tab bar/header was actually checked —
it hadn't been, and it turned out to hide the biggest gap of the chapter:** `Shell.tsx` (the shared
header/nav wrapper used by *every* screen, not part of the per-screen scope above) showed a gear
icon top-right on phone width, unconditionally. The panel's own design (Design-Audit 2026-08-13,
`.rings`/`.profilbtn` in `audi-dashboard-ios.css`) is the opposite: on phone width the gear
(`.profilbtn`) is `display:none` and the **Audi rings** sit top-right instead ("damit oben rechts
nur ein Element sitzt") — the rings are the *only* path to Einstellungen there. companion-app never
had a rings button in the narrow header at all. Fixed: `Shell.tsx`'s `kopf` now renders `<VierRinge
hoehe={17}>` (already used elsewhere for the wide sidebar) inside the same `.dm-kopf__aktion` slot,
`SymbolZahnrad` removed from that spot (still used in the wide sidebar's own footer). Also removed
`.dm-kopf__aktion[aria-current="page"]{color:var(--red)}` — dead now that the icon is an `<img>`
(color doesn't apply), and was already a violation of this project's own "red is destructive/accent
only, never a navigation state" rule (see the 2026-08-13 back-arrow/primary-button fixes) that had
simply never been caught because `Shell.tsx` isn't a "screen" any per-screen audit would reach.
`Shell.test.tsx`'s test name updated ("...über die Ringe, nicht über das Zahnrad") — the assertion
itself (`getByLabelText("Einstellungen")`) needed no change and was already passing throughout.
**Live verification unblocked, worth keeping as project infrastructure:** the first attempt to
verify this chapter live failed — CORS: `OPTIONS http://localhost:18123/api/` came back `403
Forbidden` from `audi_ha_test`, the documented constraint ("the web build is served from HA itself,
same-origin... or the native hull uses `CapacitorHttp`, which bypasses CORS" — a foreign dev-server
origin hits neither path). Added a **dev-only** Vite proxy (`vite.config.ts`, `server.proxy: {
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true } }`) — `server.*` never
applies to `vite build`, so this has zero effect on the shipped app. Pointing the onboarding
screen's server address at the dev server's own origin (`http://localhost:5173`) makes every
`/api/...` call same-origin from the browser's point of view; Vite's Node process (not subject to
browser CORS) forwards it server-side to `audi_ha_test`. Hardcoded to `:18123` specifically — never
point this at anything else. This unblocks live visual verification for every remaining chapter,
not just this one.
Verified: `npm run typecheck` clean, `npm test` 146/146 green, **and live end-to-end** against
`audi_ha_test` through the new proxy — screenshot confirms the rings render top-right (not a gear),
the Reichweite hero figure at real 60px size ("87 km" clearly dominant), the bare "15 %" with no
liter line, the service block correctly shows its chevron (a real next-service exists in this
container's data), and the tab bar order/icons match the panel.
**Owner escalation right after: "check EVERYTHING optically — every font, every field, every color,
every icon" — named three concrete symptoms (Mein Audi icon looked old, Reichweite laid out
differently, the Ölwechsel figure's font was wrong). All three were real, found by diffing CSS/SVG
source directly against the panel rather than by eye (no way to log into the real panel's frontend
from this session — LLAT tokens work for the REST/WS API, not an interactive HA session; entering a
password is off-limits per this project's standing rules — so every comparison here is source-level,
not a real pixel screenshot of the panel itself):**
1. **`SymbolAudi` (the "Mein Audi" tab icon) was a placeholder, not the real icon.**
`<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />` — a crude quadrilateral that was apparently
never replaced with the real Audi-CI "car from the front" glyph the panel uses (`ICONS.audi`,
filled/`voll`, from the myAudi icon set). Replaced with the exact same path, `voll` added to match
`ICONS_VOLL.has("audi")`.
2. **`.ads-title` (design-system, the page-title text used by `Shell.tsx`'s header) was 23px; the
panel's `.title` is 22px.** No recorded reason for the 1px drift — fixed in `design-system/src/
tokens/tokens.css` (design-system is meant to track the panel's own corrected values, see its own
`--fg3` comment from the 2026-08-11 contrast fix) and rebuilt (`npm run build` in `design-system/`
— it ships as a built package, `dist/`, so a source edit alone doesn't reach companion-app without
this).
3. **The Reichweite tile's whole layout order was inverted.** The panel's `.tankzeile` (explicitly
commented as a myAudi-app convention, the *only* tile in the whole panel with its label at the
bottom instead of the top) puts the big figure and the percentage side by side on one row
(`display:flex;align-items:flex-end;justify-content:space-between`), then the progress bar, then
"Reichweite" last. `Uebersicht.tsx` had the eyebrow label first (matching every *other* tile's
convention, but not this one's actual panel counterpart), and the figure/percentage stacked instead
of sitting in one row. Restructured to match exactly: new `.dm-tankzeile`/`.dm-tankzeile__pct` CSS
(byte-equivalent to `.tankzeile`/`.pct`), "Reichweite" label moved after the progress bar.
4. **`.dm-tgwert` (the big Ölwechsel/Inspektion figure on the Service screen's "Anstehende Termine")
had no `font-family` at all.** The panel's `.tg-wert` explicitly sets `"Audi Type Wide"`; without
it here, the number silently fell back to the narrow "Audi Type" body cut — a real, visible
different letterform, exactly the "font doesn't match" the owner described. Added
`font-family: var(--font-wide)`.
**Found but deliberately NOT fixed yet, flagged instead — needs its other call sites checked first:**
`.dm-navikachel__zahl` (the shared `NaviKachel` component's big value, used by the "Letzte Fahrt"/
"Letzte Tankung" tiles on Übersicht, and also by `Service.tsx`, `MeinAudi.tsx`, `Versicherung.tsx`)
renders at 20px/weight 300 with no `font-family` override — but the panel's actual teaser equivalent
(`.leaf .v`, the "Zuletzt" tile) is plain 13px body text, no Wide font, no hero treatment at all. This
is a real, likely bigger mismatch than the Ölwechsel one — but `NaviKachel` is shared across (at
least) five screens, some of which may have a *different* panel counterpart than `teaser()` with its
own, possibly-correct-as-is sizing. Fixing this blind risks regressing a screen not yet audited.
**Do not touch `NaviKachel`'s styling without first checking every one of its five call sites against
its own specific panel counterpart** (Chapter 2/3/4's territory, not just Chapter 1's).
Verified (items 14 above): `npm run typecheck` clean, `npm test` 146/146 green after every step,
**re-verified live** through the dev proxy at a true 375×812 mobile viewport (`resize_window` preset
"mobile" — the owner separately asked for this, since screenshots up to that point were desktop-pane-
narrow rather than an actual phone viewport): rings top-right, real car icon on "Mein Audi", the
Reichweite row/percentage/label order all confirmed matching.
**Standing method going forward, all remaining chapters:** structural/logic review alone is not
enough — cross-check every relevant CSS rule (`font-family`, `font-weight`, `font-size`, color
tokens) against the panel's own stylesheet line by line, not just JSX structure, since this is where
three of these four bugs actually lived.
**Real login access to the panel, corrected 2026-08-29:** the owner had `http://localhost:18123/
audi-dashboard` open and already authenticated in this session's own browser tool (a second tab,
separate from the companion-app dev-server tab) — contrary to the earlier "I can't log in" claim
made a few turns earlier in this same conversation. That claim was right about the actual constraint
(entering a password into a login field is off-limits, categorically, for any instance including a
disposable test one — confirmed `audi_ha_test` has no `trusted_networks` passwordless provider
either) but wrong to conclude "so I can never see the real panel" — an *already-authenticated* tab is
a different thing entirely, nothing was typed into a credential field to get it. Once pointed at it,
direct DOM/computed-style queries against the panel's real shadow root (`javascript_tool`, walking
into `audi-dashboard-panel`'s `shadowRoot`) became possible — ground truth, not inference from CSS
source. Also resized this tab to a real 375×812 viewport per the owner's request (`resize_window`
preset "mobile" on `tabId` for the HA tab too), since the panel embedded in HA's own sidebar at
desktop width is not what the phone app actually renders.
**Two more real bugs found this way, immediately:**
1. **`.ads-eyebrow` (design-system) used the pre-2026-08-13 uppercase Audi-CI eyebrow style —
10px, `text-transform:uppercase`, 0.2em letter-spacing — everywhere, across every screen.** Live-
measured against the panel's actual `.label` (used for "Reichweite" and the same role throughout):
`font-size:13px; letter-spacing:.01em; text-transform:none; font-weight:400; color:var(--fg3)`
the iOS-overlay override from the "iOS look made official" decision (2026-08-13, see the
iOS-vs-Audi-CI section above), which explicitly reversed the old uppercase convention project-
wide. `design-system`'s neutral eyebrow was never updated to follow that decision — it still
ships the pre-decision look, correctly, since it's also a standalone kit for Claude Design and the
iOS decision was scoped to this app specifically. Owner's explicit call when asked which side
should change: **"die iOS App soll optisch möglichst identisch zur vorhandenen HA App sein"** —
so companion-app overrides it locally (same pattern as the font-family override just above it),
`design-system` itself untouched. This is likely the single biggest contributor to the "looks
old/mismatched" impression, since `.ads-eyebrow` is used for nearly every tile caption app-wide.
2. **The entire Standort feature (live vehicle GPS position) was completely missing from
companion-app** — zero files, zero route, confirmed via a source-wide grep for "Standort" before
concluding it wasn't just misnamed. The panel's version is substantial: a small non-interactive
preview map on Übersicht (`standortKachel()`), a fullscreen map screen with a real vehicle marker
(CI `poi-car-l` icon + solid silhouette, the contour-only-icon problem documented elsewhere in
this file), an optional own-device marker (browser geolocation), distance-to-vehicle text, reverse-
geocoded address (Nominatim, cached), a fährt/steht/"geparkt seit" status line, tank%/range values,
Route (Google Maps deep link) and Teilen (Web Share API, clipboard fallback) actions, four floating
map-style/center controls, and a draggable peek/expand bottom sheet. Owner's answer when asked
whether this was in scope: **"hier greift wieder 1. und selbstredend muss die technische Funktion
erstellt werden"** (of course the function has to be built too).
**Built: `src/screens/Standort.tsx`** (new), exporting `StandortVorschau` (the Übersicht preview
tile, embedded there right after the service tile — same position as `standortKachel()` in `vHome()`)
and `Standort` (the fullscreen screen, registered as a new route). `navigation.ts` gained
`"standort"` (`ZURUECK.standort = "home"`, `TITEL.standort = "Standort"`); `register.tsx` wires it.
Reused `Karte.tsx`'s established pattern (Leaflet via dynamic `import()`, bundled not CDN — same
reasoning as there: this app can be network-isolated to a VPN tunnel, and a CDN fetch failing
silently was an already-documented panel-era bug) but written fresh rather than extended, since
Standort's needs (live single point + a second, independent user point, click-to-open-sheet, style
toggle) don't fit `Karte`'s start/ziel/route-triplet API. The CI marker icons (`poi-car-l` outline +
its silhouette, `map-layer`/`car-search`/`gps`/`select-all`/`plan-route`/`fuel-range`/`parking`) are
verbatim panel path data, kept local to this file (matching the existing precedent of `symbole.tsx`'s
`SymbolOelwechsel`/`-Inspektion` — "vom Nutzer als SVG geliefert, verbatim übernommen").
`fahrzeug.standortLat/standortLon/standortZeit` and `zuendung` were already present and correctly
mapped in `profilAdapter.ts` (built earlier, never consumed by any screen until now) — no data-layer
work needed, only the UI.
**One deliberate difference from `standortMenuVerdrahten()`'s exact wiring:** the panel binds
pointer handlers on the whole shadow root filtered by `closest(".standortmenu-griffzone")` and
separately gives the grip a `data-standort-umschalten` click handler through the shared central
dispatcher. Ported as React `onPointerDown/Move/Up/Cancel` directly on the griffzone element (ref-
based direct `style.transform` writes during the drag, matching the panel's "manipulate the DOM
directly, no re-render per pixel" reasoning) — but the grip button's own `onClick` was **dropped**,
not ported 1:1: a plain tap already satisfies the pointerup handler's own `Math.abs(dy) <= 40` toggle
branch, and keeping both would double-toggle on every mouse/touch tap (open-then-immediately-close).
Keyboard access (the entire reason the panel made the grip a real `<button>`, per its own comment)
is preserved via a dedicated `onKeyDown` handler for Enter/Space instead.
**Verified: `npm run typecheck` clean, `npm test` 147/147 green (147, not 146 — the existing render-
every-page smoke test picked up the new `standort` route automatically), `npm run build` succeeds.
Live end-to-end through the dev proxy at a real 375×812 viewport**, and this is the first companion-
app-only feature in this file verified against genuinely live GPS/geocoding, not just fixtures: the
preview tile rendered a real map with the real car-pin marker at the test container's actual
coordinates; opening it showed the correct empty/denied-permission copy ("GPS-Freigabe fehlt",
byte-identical to the panel's own string) since this sandboxed browser has no location permission;
dragging/tapping the sheet handle opened it to reveal a **real, successfully reverse-geocoded
address** ("St 2047, 91809 Wellheim") via a live Nominatim call, the correct "Geparkt seit 7 Std. 26
Min." status (derived from this container's actual last-trip end time), the tank%/range values, and
both the "Route"/"Teilen" pills rendered correctly (primary filled vs. outline). One tooling note
worth keeping: clicking anything that triggers `navigator.geolocation.getCurrentPosition()` makes
the browser-automation tool's click call report a 30s timeout even though the click and the
resulting render both succeed — the permission prompt blocks the tool's own completion signal, not
the page. A follow-up screenshot after the reported timeout always showed the correct, fully-
rendered result — don't mistake this specific timeout for a real failure on this page.
Manifest/OTA not bumped yet — deferred to the chapter-11 wrap-up per the standing chapter plan,
same as every other companion-app-only change so far this audit.
**Owner follow-up round, same day — three real items and one false alarm:**
1. **"Teilen" had no icon at all** — a real omission in the port, not present in the panel: `Standort.tsx`'s share button was built as bare text. The panel's own markup (`ciSVG(CI.upload, 15)}Teilen`, read directly this time rather than assumed) uses the `upload` CI icon. Added `CI.upload` to `Standort.tsx` and rendered it before the label, matching exactly.
2. **Tank/range row icon, owner-requested change: "soll identisch zum Tanken-Icon sein."** Live-extracted from the real, authenticated panel DOM (see below) that this row currently uses `CI.fuelRange` — a different, filled CI icon, not the stroke-drawn tab icon (`ICONS.fuel`) used for the "Tanken" tab. This is a deliberate product correction, not a parity bug being fixed — applied to **both** codebases per the parity rule so they don't immediately drift apart again: panel's `.standortmenu-werte` now renders `ICONS.fuel` directly (stroke SVG, matching the tab icon's own wrapper attributes exactly) instead of `ciSVG(CI.fuelRange)`; companion-app's equivalent now renders the existing `<SymbolTanken groesse={16}>` component instead of the local `CI.fuelRange` path (which was removed from `Standort.tsx`'s `CI` object as the resulting orphan). `CI.fuelRange` was also removed from the panel's own `CI` catalog (no longer referenced anywhere). Manifest bumped to `2026.8.30.1`, deployed to `audi_ha_test`, restart confirmed clean via log, live-reconfirmed via the panel's real shadow DOM that `.standortmenu-werte` now renders the fuel-pump stroke icon.
3. **"Das oberste der vier Icons ist fehlerhaft" — investigated, not reproduced.** Extracted the live-rendered top control button's full `outerHTML` from both companion-app and the real, authenticated panel and diffed them: byte-identical SVG (`viewBox`, `width`/`height`, `fill`, the `mapLayer` path itself) and identical wrapper attributes. Also functionally exercised it (not just visually inspected): clicking it in companion-app flips `aria-pressed` and swaps the tile-layer source to the real ArcGIS satellite endpoint, exactly as designed — confirmed via `read_network_requests`-equivalent direct tile-`src` inspection, not just a screenshot. Could not find a defect through either markup or behavior. Flagged back to the owner to ask for a device screenshot rather than continuing to guess blindly.
4. **False alarm, root-caused and worth recording so it isn't chased again: "iOS App Darstellung ist aktuell im Zoom."** Not an app bug — the Browser tool's own `resize_window` mobile-viewport emulation had gotten into a visibly inconsistent state across this session (screenshots of the same 375×812-declared viewport rendered at wildly different effective scales moments apart, and a tab's internal id silently changed — `tab-2` became `tab-4` — partway through, which likely contributed). Confirmed via `getComputedStyle` that the actual values were correct throughout regardless of how the screenshots looked (`.ads-title` 22px, `.ads-fig` 60px, matching the panel's own live-measured values exactly, recorded earlier this file) — the CSS was never wrong. Fixed by clearing the viewport emulation (`resize_window` preset `"desktop"` on the affected tab), which returned the pane to its normal responsive size and resolved the visual "zoom" the owner was seeing directly in the shared pane. **Lesson for the rest of this project's remaining chapters:** don't trust an apparent size/scale difference between two screenshots taken via this tool without also checking computed pixel values — screenshot scale in this tool has proven unreliable this session, computed styles have not.
Owner's broader ask alongside these four: a full, no-detail-spared pass — "Prüfe vollständig. Ich kann dir nicht jede Abweichung aufzeigen" (check everything myself; fonts, frames/backgrounds that shouldn't exist, the tab-bar active-icon treatment) — explicitly including a standing offer to say if any font or icon asset is missing.
**Tab-bar active state, checked immediately after — a real, significant bug, the biggest single-CSS-rule find of the audit so far.** Live-measured both: the panel's active tab (`.tab.on` in `audi-dashboard-ios.css`) gets a background pill behind icon+label (`rgba(255,255,255,.14)` dark / `rgba(0,0,0,.07)` light, `border-radius:14px`, text color `var(--fg)` — no red anywhere). `design-system`'s `TabBar.css` `.ads-tab--on` instead used `border-top-color: var(--red)` — a **red top border**, a completely different mechanism, and a violation of this project's own long-standing "red is accent/destructive only, never a navigation state" rule (the same rule that removed red from the back arrow, primary buttons, and the Arbeitsweg pill in 2026-08-13/17) that `design-system` was simply never updated to follow. Fixed directly in `design-system` (same reasoning as the `.ads-title` fix earlier — this is a plain oversight, not a deliberate fork like the eyebrow case): removed the red border-top mechanism entirely, added the measured background-pill treatment with a light/dark override (`[data-theme="tag"]`). Rebuilt the package; verified live (`background-color: rgba(0,0,0,.07)`, `border-radius: 14px`) and via screenshot — grey pill behind the active tab, no red.
**Fourth reported round, same session — four more items, three real bugs and one clarification:**
1. **Grey card backgrounds where the panel has none.** The RS4/Kennzeichen block and the Reichweite tile were both plain `<Tile>` (default variant, `background: var(--tile)`) — but their panel counterparts are both `class="tile flat"` (confirmed live: `background-color: rgba(0,0,0,0)` on all three `.tile.flat` instances on this screen). `design-system`'s `Tile` already has a `variant="flat"` that does exactly this and was simply never used here. Fixed: `Uebersicht.tsx`'s Reichweite `<Tile>``<Tile variant="flat">`; the vehicle-image block's own wrapper (`.dm-szene`) had its `background: var(--tile)` removed directly in `screens.css` (it isn't a `<Tile>` at all, a plain div, so the fix lives in its own class).
2. **Fahrzeugbild too large.** Root cause: `ImagePlaceholder`'s default size is `aspect-ratio: 4/3` — correct for the gallery/wheel-photo usages elsewhere, but the panel's Übersicht image specifically uses `"carfix klein"` (16:8), and live-measured the panel's actual box at only ~120130px tall regardless of width. Rather than changing the shared component's default (would wrongly affect every other correctly-sized usage), scoped an override to `.dm-szene .ads-bildbox { aspect-ratio: 16/8 }` in companion-app's own `screens.css` — same "local override, shared component stays generically correct" pattern used for the font-family and eyebrow fixes.
3. **"Satelliten-Icon fehlerhaft" — investigated a third time, including now with the owner's own screenshot as a reference, still not reproduced.** Re-diffed `outerHTML`, then went one level deeper than the earlier pass and checked actual *computed* paint values on the live path element: `fill: rgb(16,20,24)` (correctly resolves the day-theme icon color), `fill-rule: nonzero` (default, same as the panel — no override exists on either side), `stroke: none`. Every measurable value matches. The icon (`mapLayer`, a 3-band stacked-diamond glyph) is genuinely hard to read at 22px in plain monochrome, which may be exactly what looks "off" in a screenshot — but that ambiguity is identical in both apps, not a companion-app defect, as far as three independent checks (markup, function, paint) can tell. Left as an open question back to the owner rather than guessed at further.
4. **"Standort Slide Up ist leer" — a real bug, found via DOM inspection, not a data/content problem.** The sheet's own content was always complete and correct (address, status, tank/range, both action buttons all present and populated in the DOM even while "empty"-looking) — confirmed directly: `.dm-standortmenu`'s measured `transform: translateY(147px)` against its own `243px` height correctly implements the 96px peek reveal, exactly as designed. The actual bug was one level up: `.dm-standort-vollbild` was capped at `height: min(640px, calc(100dvh - 190px))` (copied verbatim from the panel, correct *there* because the panel's whole app is itself boxed into an ~880px-tall card) — but companion-app's page has no such outer cap, so on a taller viewport the capped map+sheet left a large stretch of plain page background below it, the same canvas color as the (correctly) mostly-hidden sheet — reading as one big empty area. Fixed by making `.dm-standort-vollbild` fill the remaining flex space instead of capping (`.dm-inhalt > .dm-standort-vollbild { flex: 1 1 auto }`, overriding rahmen.css's blanket `.dm-inhalt > *{flex:none}` for this one screen specifically — the only screen whose entire content is one full-bleed interactive area with nothing else below it). Verified live: the map+sheet now fill the full screen down to the tab bar, no leftover blank strip, on the same viewport that showed the bug before.
Verified (this round): `npm run typecheck` clean, `npm test` 147/147, live-reconfirmed via reload + screenshot for all three real fixes (flat tiles transparent, image band compact, Standort screen fills to the tab bar). No panel-side changes this round — all four items were companion-app-only bugs, not places where the panel and app had made a deliberate, disclosed different choice.
**"Diese App ist älter als der Server — bitte aktualisieren" explained (owner asked directly):** this is the app's own compiled-in version (`__APP_VERSION__`, baked in from `manifest.json` at `vite build`/`vite dev` startup) compared against what the backend currently reports (`sensor.audi_dashboard_app_version`) — see `VERSIONIERUNG.md`. It's doing its job correctly right now: this session bumped the manifest to `2026.8.30.1` for the panel-side icon fix without restarting the *dev server process* (only reloading the page), so the running dev server is still serving the previous version's compiled-in number against a backend that has since moved on. Not a bug — the banner is the intended, working signal for exactly this situation, and would clear on the next full dev-server restart (or, in the real native app, the next OTA/Xcode rebuild).
**Next planned step, still open**: continue the systematic Shell.tsx/shared-chrome sweep the owner asked for, screen by screen, now informed by the pattern this round has repeatedly found — check every `<Tile>` variant choice (flat vs. default) and every locally-sized image/icon against the panel's live DOM, not just structure/logic.
## The single biggest find of the whole audit: `design-system`'s color/radius tokens were read from the wrong stylesheet (2026-08-30)
Owner, after several rounds of individually-fixed bugs: **"Font, Schriftgröße, Formatierung, Zeilenabstand, etc. sind noch wesentlich abweichend. Prüfe das immer mit."** — do a real typography sweep, not just background/size spot-fixes, and make it standing practice from here on.
Ran a systematic computed-style diff (font-family/size/weight/line-height/letter-spacing/color) across every text element on Übersicht, panel vs. companion-app, live. Two things fell out immediately:
1. **The `--fg2`/`--fg3` colors this file's very first color-token comparison (chapter 1) called "match exactly" don't, in practice — because that comparison only ever read `:host`/`:host([data-theme=...])` in the *base* `audi-dashboard.css`.** The panel **always** loads a second stylesheet on top, `audi-dashboard-ios.css`, which redefines nearly the entire palette (confirmed live via `getComputedStyle` on the real panel's `:host` — not by reading source, since two stylesheets cascading needed a live answer, not a static one). The base-file values were never actually reaching the screen in the real app; `design-system` had been quietly built against a dead reference the whole time. Corrected `design-system/src/tokens/tokens.css` to the actual live values for both themes:
| Token | Old (base-file) | Now (live iOS-overlay value) |
|---|---|---|
| `--canvas` (nacht/tag) | `#161b23` / `#FFFFFF` | `#0C1014` / `#F2F2F7` |
| `--tile` | `#1f2733` / `#f2f2f2` | `rgba(255,255,255,.055)` / `#FFFFFF` |
| `--tile-2` | `#2a3341` / `#e5e5e5` | `rgba(255,255,255,.10)` / `#EFEFF4` |
| `--line` / `--line-strong` | grey-based rgba | `rgba(255,255,255,…)` / `rgba(60,60,67,…)` (iOS separator convention) |
| `--fg2` | `#9aa1ad` / `#4c4c4c` | `#BFC4CC` / `#3C3C43` |
| `--fg3` | `#8a94a3` / `#666666` | `#8E8E93` / `#8E8E93` (same both themes — iOS `tertiaryLabel`) |
| `--ok`/`--warn`/`--bad` | Android-Material-ish greens/reds | iOS `systemGreen`/`systemOrange`/`systemRed` |
| `--r-tile` | `20px` | `16px` |
This single fix touches nearly every screen at once — every tile background, every secondary/tertiary text color, every corner radius in the app was drawn from the wrong palette. It's almost certainly the largest single contributor to "sieht insgesamt anders aus" of anything found this session, bigger than the individual per-element fixes around it. Also corrected in the same pass: `.ads-title` `letter-spacing` (`-.015em``-.01em`) and `line-height` (`1.15``1.2`), both live-measured against the real `.title`.
2. **The badge "zusatz" text (e.g. "Avant Competition" next to the model badge) was rendering at 22px/black/inherited-title styling — the panel's is 13px/`var(--fg2)`/plain.** Root cause in `Uebersicht.tsx`: the wrapping `<div>` incorrectly carried the `ads-title` class (only needed for the no-badge-detected text fallback path, but applied unconditionally), and the inner `<span>` used `.ads-sport` (italic, a style meant for something else, not this spot — the panel's `.badge .zusatz` is plain, not italic). Fixed: wrapper is now plain `dm-szene__zusatz` with no `ads-title`; the local `.dm-szene__zusatz` CSS rule (which had its own separate `font-size:19px` override, likely written to visually "match" the wrongly-inherited 22px title rather than the panel) corrected to `font-size:13px; color:var(--fg2); line-height:normal`; the inner span dropped `.ads-sport` entirely.
Rebuilt `design-system`, verified live: `.ads-title` letter-spacing/line-height now `-0.22px`/`26.4px` (byte-match to the panel's measured values), zusatz now `13px`/`rgb(60,60,67)`/non-italic (matches), `.ads-eyebrow` color now resolves to the correct `rgb(142,142,147)` (this was already using `var(--fg3)` from an earlier fix — it was correct code all along, just pointed at a wrong token value until this round). `npm run typecheck` clean, `npm test` 147/147.
**Standing rule from here on, per the owner's explicit instruction:** every remaining screen check must include font-family, font-size, font-weight, line-height, and letter-spacing — not just background/sizing — and should be verified against **live computed styles** on the real panel, not against static CSS source, given source-reading already produced one wrong "verified matching" conclusion this session (the token comparison above).
**Finishing the Übersicht typography pass, same round — three more real bugs, found by owner report and by the same live-computed-style method:**
1. **"87 km falsche Font/Formatierung."** `.ads-fig`'s `letter-spacing` was `-.02em` — copied from the *base* `audi-dashboard.css`'s `.fig`, the same class of mistake as the color tokens above: the iOS overlay overrides it again, separately, to `-.03em` (`audi-dashboard-ios.css: .fig{letter-spacing:-.03em}`, live-confirmed `-1.8px` at 60px = `-.03em`, not the `-1.2px`/`-.02em` companion-app was rendering). Fixed in `design-system/src/components/Fig/Fig.css`. Worth remembering going forward: **any class with a same-name rule in `audi-dashboard-ios.css` needs the overlay's value, not the base file's** — this is now the second time (colors, now letter-spacing) the base file alone gave a wrong answer.
2. **"Nächster Service und 8.600 km sind zu nah zusammen."** Confirmed: the panel's `.fig-zeile` (icon+figure row) has `margin-top:8px` relative to the label above it; companion's equivalent `.dm-servicefig` had none at all — zero gap. Added `margin-top:8px` to `.dm-servicefig`, plus the same margin on `.dm-serviceblock > .ads-fig` for the no-icon fallback case (Hauptuntersuchung), where the panel applies the identical margin directly to the bare `.fig` instead of a wrapper row.
3. **The "Letzte Fahrt"/"Letzte Tankung" teaser tiles, flagged earlier this session as "found but not fixed — check other `NaviKachel` call sites first."** Checked: `.dm-navikachel__zahl` (the 20px/weight-300 "hero number" treatment) turned out to be used **only** at these two call sites in the entire app (`grep` confirmed) — every other `NaviKachel` usage (`MeinAudi.tsx`, `Service.tsx`, `Versicherung.tsx`) passes a plain string through the ordinary `.dm-navikachel__wert` (untouched, out of scope). Safe to fix directly: the panel's real teaser value (`.leaf .v`/`.leaf .v small`, live-measured) is plain — `15px`/weight `400`/`var(--fg2)` for the value, `12.5px`/`var(--fg3)` for the caption — no Wide font, no hero treatment at all. `.dm-navikachel__zahl` corrected to those exact values; a new, more specific `.dm-navikachel__wert .dm-navikachel__zahl + small` rule added for the caption (a same-specificity `.dm-navikachel__zahl + small` was tried first and silently lost to the pre-existing `.dm-navikachel__wert small` rule later in the file — needed the extra specificity, not just presence, to actually win the cascade).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-reconfirmed for all three (`.ads-fig` letter-spacing `-1.8px`, `.dm-servicefig` margin-top `8px`, teaser value `15px`/`400`/`rgb(60,60,67)` and caption `12.5px`/`rgb(142,142,147)`) — plus a screenshot: visible gap now between "Nächster Service:" and "8.600 km", teaser tiles now read as plain text like the panel instead of oversized figures.
**Actually not quite closed — two more real bugs, found right after "closing" it:**
1. **Dev server itself got into a broken state ("iOS App freezed again")** — not an app bug. After many rapid `design-system` rebuilds + manifest bumps + hot-reloads this session, the Vite dev server's HMR/websocket state had visibly desynced (console showed dead `ws://localhost:5173/` connections and a stale `ReferenceError: StandortVorschau is not defined` that survived plain page reloads). Fixed by a full `preview_stop`/`preview_start` cycle, not just a reload — this also cleared the lingering "Diese App ist älter als der Server" banner from the earlier manifest bump, since the fresh server re-embeds the current version. **Worth remembering for the rest of this audit: a stuck/frozen-looking dev preview after several rebuilds in one session needs a real server restart, a page reload is not enough.**
2. **"RS4 + Kennzeichen not in line on left sided" — real, confirmed via bounding-rect measurement on both apps.** Live-measured the panel: the Kennzeichen text and the "Reichweite" label sit on **exactly the same left edge** (`left:16px` both — both live inside a zero-padding `tile flat`). Companion-app measured three *different* left edges for elements that should share one: Kennzeichen `38px`, "Reichweite" `22px`, "Nächster Service" `44px`. Root cause: `.dm-szene__text` (wrapping the badge/model-name/Kennzeichen block) carried its own `padding: var(--sp-4) var(--sp-4) var(--sp-5)` — a left/right inset the panel's equivalent (a zero-padding flat tile) never has. Fixed: `padding: var(--sp-4) 0 var(--sp-5)` — keeps the vertical spacing (gap under the image, gap before the next tile), drops the horizontal one. Verified live: Kennzeichen and "Reichweite" now both `left:22px` (companion-app's own page-padding equivalent of the panel's `16px`), byte-equal to each other.
**Flagged, not fixed — a bigger, systemic question, out of scope for a quick fix:** "Nächster Service" still sits at `44px`, not matching the `22px` the other two share — because `.ads-tile`'s own internal padding is `var(--sp-5)` (22px) uniformly, while the panel's equivalent `.tile` padding is `16px 18px` (different value, and asymmetric top/bottom vs. left/right). This is a **global** `Tile` padding question (affects every tile on every screen, not just this one), not a one-line local fix like the others in this round — noted here so it isn't lost, not yet acted on.
Verified (this round): `npm run typecheck` clean, `npm test` 147/147, dev server confirmed responsive after restart, alignment fix confirmed via matching bounding-rect `left` values and a screenshot.
**This closes the Übersicht screen for this audit** (owner confirmed via "erst die Übersicht noch zu Ende typografisch durchgehen … go for it"). Every text element on it has now been individually live-diffed against the panel at least once. Next: Mein Audi, same method (structure **and** full typography from the start this time, not retrofitted after the fact) — and now also carrying forward the open `Tile`-padding question above, worth checking again on the next screen before deciding whether it's a real systemic fix or specific to this one spot.
## Chapter 3 addendum: `Reifen.tsx` was structurally wrong, not just styled differently (2026-08-30)
Owner, mid-Chapter-5: "reifen sieht völlig unterschiedlich aus und passt nicht zu meiner identisch
vorgabe." Investigated `vReifen()` (panel, line 2924) against `Reifen.tsx` and found this screen had
never actually been brought in line — Chapter 3's entry above only added the two missing edit
features (Anzugsmoment/km pencils) onto the *old* structure, never checked the structure itself
against the panel.
**Real structural differences, all fixed:**
1. Panel shows **both** Sommerräder and Winterräder tiles simultaneously, always — companion-app used
a `Seg` toggle to show only one at a time. Removed `Seg`; both `satzTile("sommer")`/
`satzTile("winter")` now render unconditionally.
2. Panel has Anzugsmoment as its own top tile (eyebrow + big `.fig` value + gear-style pencil) — this
was already right from the earlier Chapter-3 pass, kept.
3. **Wechseltermin was previously folded into a combined "Montiert" tile as a read-only
`Wertzeile` + a plain date `<Feld>`** with no "Termin vereinbart" toggle at all. Rebuilt as its own
tile matching `vReifen()`'s: `Geplant am` date field, a `Switch` for `Termin vereinbart` (new —
didn't exist here before), and "In den Kalender übernehmen" — mirroring the panel's own quirk that
this one date/checkbox pair only actually saves when that button is clicked (`kalenderUebernehmen()`
saves via `profilSpeichern` and immediately downloads the `.ics`, matching `data-ics="reifen"`'s
`$("#rfDat")`/`$("#rfOk")` read at click time).
4. **Each satz tile's Hersteller/Modell/Größe/DOT/Solldruck/Kommentar fields were entirely read-only**
(`Werteliste`/`Wertzeile`) — a real functional gap, not just a display difference: there was no way
to edit a tire set's own data at all (only the archived sets, via `ArchivZeile`, were editable).
Panel's `data-rf` fields save live on `change`. Ported the same immediate-save behavior via
`onBlur` (React's closest equivalent to native `change` for text inputs) — `feldSpeichern()`, one
`profilSpeichern` call per field per satz.
5. Layout: `.radkopf-row` (headline+km left, wheel photo right, both top-aligned) — new
`.dm-radkopf-row`/`.dm-radkopf`/`.dm-tile-headline`/`.dm-reifenzeile`/`.dm-radfoto` CSS in
`screens.css`, live-measured off the panel's own `.radkopf-row`/`.tile-headline`/`.fig`/`.km-edit`
rules. `.dm-montiert`/`.dm-montiert--an`/`.dm-archivbtn` similarly copied from `.montiert`/
`.montiert.on`/`.archivbtn` (outline pill, filled black/canvas when the satz is the active one — was
previously a single "Auf X wechseln" `ActionButton`, not a per-satz clickable pill).
6. `montierenAuf()` (the per-satz "Montiert" pill) calls `reifen_wechseln` **without a confirmation
dialog** — the panel's own `data-satz` handler has none (`bestaetigen()` is only used for
*archiving*, not for switching); the old `wechseln()` function had one, removed to match exactly.
7. Kommentar field made full-width (label above, not beside) after a live check showed "Eingelagert
beim Autohaus" truncating in the label-beside-input layout — same `.feld--breit`/bypassed-`Feld`
pattern already established in `Versicherung.tsx`'s Vertragsnotiz.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — both tire sets render simultaneously with real data (Sommerräder: 275/30 R20 97Y XL,
2,5/2,4 bar; Winterräder: Michelin Pilot Alpin 5, 265/35 R19, 3822, 2,65/2,5 bar, "Eingelagert beim
Autohaus" — none of this was previously visible or editable), Montiert pills correctly filled/outline
per active satz, Archiv section unaffected.
## Chapter 5 (Sicherheit) closed: three confirmed issues fixed in `Fahrzeugstatus.tsx`, plus a real
structural bug in `TuerenKlappen.tsx` found while verifying it (2026-08-30)
Continuing the audit resumed after the Reifen interruption above. `Fahrzeugstatus.tsx` vs. the
panel's `vSicherheit()` (line 1541) had three confirmed-but-unfixed issues from the prior pass, now
fixed:
1. **Missing "Draufsicht" top-view image** — added at the top via the existing `.dm-szene` wrapper
(same 16:8 treatment already established for Übersicht's vehicle image), reading
`"draufsicht.webp"` — which itself needed adding to `bilder.ts`'s `WEITERE_BILDER` list first
(confirmed via the backend's `bilder.py` that this filename is a real, recognized upload slot;
companion-app's own list was simply missing it).
2. **Wrong top-status indicator** — the top summary box used `<StatusRow>` (a plain colored dot),
inconsistent with both the panel (which reuses its checkmark-circle `statusKreis()` for the
summary too) and with the four detail rows below it (which already correctly use `<StatusKreis>`).
Replaced with a plain `<Tile>` + `<StatusKreis ok={gesichert}>` + text span, matching the panel's
`style="display:flex;align-items:center;gap:12px;padding:16px 20px"` box exactly — new
`.dm-sicherheitssammel` CSS in `screens.css`.
3. **Extra subtitle not in the panel** — the `statusStand`/timestamp line under the summary text was
dropped entirely (the panel's equivalent box has no such line); the now-unused `statusStand`
import was removed from the component.
**Found while verifying — `TuerenKlappen.tsx` didn't match its panel counterpart `vTuerenKlappen()`
(line 1576) either**, a real structural gap beyond a quick styling pass: it used a bespoke
`.dm-pruefliste`/`.dm-punkt` list (dot-before-label, single-column) instead of the established
`.dm-wertzeile` dt/dd row (label left at full strength, dot+status right in the secondary tone) every
other detail row in the app uses — and the panel's `vTuerenKlappen()` deliberately uses the *plain*
dot (`.dot`, not the checkmark circle from `vSicherheit()`) for this specific detail list. Rebuilt to
reuse `.dm-wertzeile` + `@audi-dash/ui`'s own `.ads-dot`/`.ads-dot--ok/--warn/--bad` (11px, byte-match
to the panel's `.dot`), removing the now-orphaned `.dm-pruefliste`/`.dm-punkt` CSS entirely (grepped
first — the only other reference was one test assertion, updated to check `.dm-wertzeile` instead).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — Draufsicht placeholder renders at the top, the summary box shows the green
checkmark-circle + "Fahrzeug ist sicher abgestellt" with no timestamp line, all four detail rows show
checkmark-circles with only "Türen und Klappen geschlossen" carrying a chevron, and its detail page
now shows the plain colored dot beside "zu"/"offen"/"unbekannt" on the right with the door/Kofferraum/
Motorhaube label at full strength on the left — matching `vTuerenKlappen()` exactly.
**Chapter 5 is closed.** Next: Chapter 6 (Fahrten) at the same depth, per the owner's "1" choice.
## Chapter 6 (Fahrten): mostly already correct, three real gaps found and fixed (2026-08-30)
`Fahrten.tsx`/`FahrtDetail.tsx`/`FahrtFelder.tsx` were largely already in good shape (the fields, the
edit-toggle idiom, the "Verbrauch" row, the Art `Pill` colors were all already right from earlier
work this session/project). Three real, fixable gaps found by diffing against `vTrips()`/`vTrip()`/
`fahrtFelder()`/`initMap()`:
1. **The trip list rows never showed the route at all.** `vTrips()`'s primary line per row is
`strecke(t)` — "Start → Ziel", one side if only one is known, or "Fahrt ohne Ortsangabe" — with
date+Art as the small caption. `Fahrten.tsx`'s rows showed only the km figure and a date/time/
duration caption; the actual journey (the single most identifying fact about a trip) was nowhere
in the list, only reachable by opening each trip individually. Rather than inverting the row's own
established primary/secondary convention (this codebase consistently makes the *value* the row's
`titel` across every list screen — Tanken.tsx included — even though the panel itself isn't
consistent about it either: `vTrips()` leads with route, `vTanken()`'s tank list leads with date),
added a `strecke()` helper (same three-case logic) and prepended it to the existing `unter` caption
line, closing the actual missing-information gap without fighting the app's own established idiom.
2. **`.dm-detail__zahl` was 40px everywhere, including `FahrtDetail.tsx`/`TankDetail.tsx`.** The panel
draws the hero figure at 52px specifically for `vTrip()`'s distance and the tank-detail's liters
(confirmed via grep: `style="font-size:52px"` at both call sites, vs. 40px/46px/34px elsewhere) —
40px is correct for the *other* detail screens (Reifen archive, Vertrag, ...) that share the same
class, so the fix is a new `.dm-detail__zahl--gross` modifier applied only to those two screens,
not a change to the shared default.
3. **`Karte.tsx`'s start and end markers were both plain red circles**, visually indistinguishable —
the panel's `initMap()` draws Start as a black-filled, white-ringed circle and Ziel in red
specifically so the two ends read differently at a glance; also added the small intermediate
red dots along a real GPS route (`route.slice(1,-1)`) that the panel draws and companion-app
didn't, and matched the polyline's `weight:2.5`/`opacity:0.9` (was `weight:2`, no opacity).
**Checked and left alone, deliberately:** `LiveFahrt.tsx` has no panel counterpart at all (no
`vLiveFahrt()` exists in the panel — this is a companion-app-only screen, explicitly marked in its own
header comment as built-but-disabled pending the FMM003 hardware), so there's nothing to diff it
against; the Art pill on `FahrtDetail.tsx` is decorative-only (no click-to-toggle like the panel's
`data-art` button) — capability still exists via the "Bearbeiten" form, an already-established
edit-toggle-vs-inline idiom difference, not a gap.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the trip list now shows "Ingolstadt → Nürnberg" / "Fahrt ohne Ortsangabe" per row, and
the trip detail's distance figure ("95,0 km") renders visibly larger, matching the panel's 52px hero
treatment. The marker-color fix in `Karte.tsx` is a straightforward style-parameter change, verified
by `tsc`/tests only — this test container's trips have no GPS track to render live.
**Chapter 6 is closed.** Next: Chapter 7 (Tanken) at the same depth.
## Chapter 7 (Tanken): a real string bug, missing accordion detail, and a completely missing
SmartDeal feature plus edit capability — all fixed (2026-08-30)
Diffed `Tanken.tsx`/`TankDetail.tsx` against `vFuel()`/`vFill()`/`tankFelder()`/`tankFormularWerte()`.
1. **Real bug: the plural of "Tankvorgang" was broken.** `` `${de(n)} Tankvorgang${n === 1 ? "" : "̈e"}` `` —
the non-1 branch was a bare combining-diaeresis-plus-"e" (`"̈e"`), not the actual word change
"Tankvorgänge" needs (a→ä, not just an appended suffix — unlike "Fahrt"/"Fahrten", which *is* a
suffix). Rendered as "Tankvorgang" with a stray floating umlaut. Fixed with explicit singular/
plural branches ("1 Tankvorgang" / "N Tankvorgänge").
2. **Year/month accordion headers showed only the cost total — the panel's secondary line (liter sum
+ average price for the year, average price for the month) and the SmartDeal-Ersparnis pill were
entirely absent.** `Accordion`'s `summary` prop already accepts a `ReactNode` (confirmed by reading
the component), so this needed no new component — a multi-line flex-column summary matching
`vFuel()`'s own markup byte-for-byte (17px/`var(--fg)` cost, plain secondary line, pill only when
`CAR.smartdeal.aktiv` and the period's discount sum is positive).
3. **SmartDeal was entirely unimplemented in this screen** — grepped the whole `Tanken.tsx`/
`TankDetail.tsx` for "smartdeal"/"discount"/"markenlogo" and found only `TankDetail.tsx`'s existing
read-only "Ersparnis" row (already correct, pre-existing). Everything else was missing: no brand-logo
image, no way to *enter* an Ersparnis value anywhere, no Kraftstoff selector in either form (the
panel's `KRAFTSTOFFSORTEN` list — `["V-Power Racing", "Super Plus", "Super"]` — had no companion-app
equivalent at all). Added `KRAFTSTOFFSORTEN` as a shared export in `bausteine.tsx` (used by both
`Tanken.tsx` and `TankDetail.tsx`), a `markenlogoUrl()` helper in `bilder.ts` pointing at the panel's
own bundled asset (`custom_components/audi_dashboard/frontend/bilder/shell-logo.svg`, served at
`/audi_dashboard_static/bilder/...` — a genuinely different static path than `/local/bilder/`'s
user-uploaded vehicle photos, confirmed by reading `const.py`'s `STATIK_URL` vs `BILDER_ORDNER`), and
wired a `<Pill>` with a graceful `onError`-hidden `<img>` for the year summary (matches the panel's own
`onerror="this.closest(...).classList.add('logo-fehlt')"` fallback intent — no broken-image icon,
just the text). Kraftstoff + a conditional (`fahrzeug.smartdeal.aktiv`) SmartDeal-Ersparnis field
added to `TankFormular` (new-entry form), both already supported by the API layer's
`TankvorgangFelder` (`kraftstoff`/`ersparnis` fields existed, just never surfaced in any form).
4. **`TankDetail.tsx` had no editing capability at all** — no "Bearbeiten" button, no edit form,
unlike `FahrtDetail.tsx`'s already-existing `FahrtBearbeiten`. The only way to correct a tank
entry's values was re-uploading a receipt. Built `TankBearbeiten`, mirroring `FahrtBearbeiten`'s own
idiom exactly (local draft state pre-filled from the record, `api.tankvorgangAktualisieren`,
Speichern/Abbrechen) with the full field set from `tankFelder()`: Zeitpunkt, Kilometerstand,
Gefahrene Distanz, Kraftstoff, Getankte Liter, Kosten, SmartDeal-Ersparnis (conditional), Tankstelle.
**Checked and left as an accepted idiom difference, consistent with Chapter 6's own finding:** the list
row's primary/secondary field choice (value-primary here, matching `Tanken.tsx`'s and `Fahrten.tsx`'s
own established, consistent convention) vs. the panel's date-primary — station name is already present
in the row's caption, so no information is missing, only reordered; and no page-level delete button on
`TankDetail.tsx` (delete already reachable via the list's `Zeilenmenue`, matching the same precedent
already accepted for `FahrtDetail.tsx` in Chapter 6).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — "13 Tankvorgänge" (was garbled), year/month accordions show the cost+liter/price
secondary line and a "29,76 €"/"12,90 €"/"20,36 €" SmartDeal pill per year (image gracefully hidden
in this dev environment since `/audi_dashboard_static` isn't proxied — text still shows, confirming the
fallback works), the tank detail's liter figure renders at the corrected 52px, and the new "Bearbeiten"
form opens fully pre-filled with real values (Kraftstoff "V-Power Racing", 42.13 l, 92.6 €, "Shell,
SÖLDEN", SmartDeal-Ersparnis field present) and "Abbrechen" correctly discards without persisting.
**Chapter 7 is closed.** Next: Chapter 8 (Statistik) at the same depth.
## Chapter 8 (Statistik): one real typography bug found and fixed (2026-08-30)
`Statistik.tsx` was already structurally solid — `StatGrid`/`Fig`/`ProgressBar` map cleanly onto
`quad()`/`.fig`/`.bar`/`.legend`, and the "Art der Fahrten" red-segment bug was already fixed earlier
this session (section Y). One real mismatch found by diffing computed styles against the panel's CSS:
- **`@audi-dash/ui`'s `StatGrid` caption (`.ads-quad__l`) used the old, pre-iOS-decision uppercase-
eyebrow style** (9px, `text-transform:uppercase`, `letter-spacing:.12em`) — the panel's `.quad .l`
is plain small grey text (11px, no letter-spacing, no uppercase). Same root cause as the
already-fixed `.ads-eyebrow` (design-system ships the pre-2026-08-13 Audi-CI convention on
purpose, for its other, brand-free audience; this app's own "match the iOS-look panel exactly"
decision needs a local override) — added right next to `.ads-eyebrow`'s own override in
`audi-schrift.css`, same reasoning, same file.
**Checked and left alone, deliberately:** the panel's `statTile()` header is collapsible
(`aria-expanded`, tap to reveal the body) with a secondary "extra" value shown next to the big figure
in the collapsed state; companion-app's tiles are always fully expanded, so there's no collapsed state
to preview into, and the same numbers are already visible in the `StatGrid` below — not a content gap,
an already-established "friendlier default" idiom difference (same category as this app's non-hiding
list rows elsewhere), left as-is rather than retrofitting an accordion onto five already-substantial
tiles. Also noticed live: "Strecke bis zum letzten Tankstop" showed a negative km value in this test
container's data — traced to `langzeitverbrauch()` in `daten/statistik.ts`, which is a byte-faithful
port of the panel's own `langzeitverbrauch()` (identical unguarded `mitOdo[last].odometer_km -
mitOdo[0].odometer_km` subtraction) — the panel would show the identical negative number against this
same underlying stray-odometer test data (the same class of issue already documented at length in
section AB item 3 for the Tanken form). Not a companion-app-specific defect and not fixed here, since
"fixing" only this side would make the two diverge instead of match.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the quad captions ("Jahr"/"Monat"/"Woche"/"Tag") now render at 11px, sentence case, no
letter-spacing, matching the panel exactly.
**Chapter 8 is closed.** Next: Chapter 9 (Batterie) at the same depth.
## Chapter 9 (Batterie): the voltage chart used a flat red line/dots instead of the panel's
health-color-coded points (2026-08-30)
`Batterie.tsx`/`BatterieListe.tsx` were already extensively parity-worked earlier this session
(sections R/S/T/X) — the fixed 1015V axis, the dynamic per-pixel viewBox, the min-only dots (no
separate max line), the tap-for-tooltip behavior, the `AGM_RUHE_MAX_V=13.0` classification threshold
and the `LADEKURVE` SOC table were all already byte-matched to the panel. One real mismatch remained,
found by reading `bvZeichnen()`'s actual drawing code line by line: the panel's line and dots are
**not** drawn in red at all — the polyline is `var(--fg)` (neutral), and each dot is colored by
`bvFarbe()`, a red→yellow→green gradient computed from the voltage itself (`≤11.6V` = bad, `11.612.2V`
= bad→warn blend, `12.212.8V` = warn→ok blend, `>12.8V` = ok), reading the actual `--bad`/`--warn`/
`--ok` CSS custom properties and RGB-interpolating between them (`bvHexZuRgb()`/`bvMischen()`/
`bvFarbstufen()`). Companion-app's `Verlaufsdiagramm` instead drew the line **and every dot** in a flat
`var(--red)` — a real violation of this project's own long-standing "red is accent/destructive only,
never a plain data color" rule (the same category as the Statistik/TabBar red-color findings earlier
this session), just never caught because nothing had diffed the actual SVG drawing code against the
panel's before. Ported the identical hex→RGB / linear-mix / threshold logic (`hexZuRgb`/`mischen`/
`farbstufenLesen`/`farbeFuerSpannung` in `Batterie.tsx`), reading the tokens live via
`getComputedStyle(document.documentElement)` (with a hardcoded fallback triple per color, for the
jsdom test environment where the real stylesheet isn't loaded) — the line switched to `var(--fg)`, each
circle now gets its own computed health color instead of the fixed red.
**Checked and left alone:** `BatterieListe.tsx`'s row layout, colors, and the "Generatorspannung" pill
threshold were already correct (verified earlier this session, section AA/S) — re-diffed against
`vBatterieliste()`'s actual markup this round and confirmed still byte-matching (k/v→titel/neben/unter
value-primary idiom, same accepted pattern as Fahrten/Tanken).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the chart line now renders black/neutral (not red) and the dots render in their
health-mapped color (a yellow/warn-range dot confirmed at a real ~12.212.4V data point in this test
container's history), matching the panel's intent exactly.
**Chapter 9 is closed.** Next: Chapter 10 (Einstellungen/Datensatz) at the same depth.
## Chapter 10 (Einstellungen/Datensatz): four app-wide `@audi-dash/ui` bugs found — the single
biggest-impact chapter of the whole audit (2026-08-30)
Diffed `Einstellungen.tsx` against `vEinst()`. `Einrichtung.tsx` (server-address/token onboarding) has
no panel counterpart at all — a companion-app-only concept, correctly out of scope. Three real content
gaps in `Einstellungen.tsx` itself, all fixed:
1. **No "Backup importieren" — a real, one-directional data-loss risk.** `profilExportieren()` could
download a combined `{profil, fahrten, tankvorgaenge}` JSON backup, but nothing could read one back
in — the panel's `data-backup="import"` handler (→ the `backup_wiederherstellen` service) had no
companion-app counterpart at all. Built `backupImportieren()` (hidden file input, `FileReader`,
`JSON.parse`, validates `profil` is present, calls the same service directly via
`api.rest.dienstAufrufen` matching this file's own existing "Jetzt sichern" call style, then
`neuLaden()`), wired as a third button ("Von diesem Gerät einspielen") next to "Auf dieses Gerät
laden".
2. **No "Version" tile at all** — the panel's `vEinst()` shows the backend's own installed
`APP_VERSION` ("Installiert: ..."). companion-app fetches this exact value already
(`appVersionAngabeLesen()`'s `.app` field, stored as local `serverVersion` state in
`DatenKontext.tsx`) but only ever exposed the *comparison result* (`versionsstand`) through context
— the raw string was fetched and then discarded. Added `serverVersion` to `DatenWert`/the context
value, and a "Version" tile in `Einstellungen.tsx` right before "Integration-Update" (matching the
panel's tile order).
3. **SmartDeal tile was structurally simplified in two ways that lost information/behavior**: no brand
logo next to the label (panel: `<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}">` before "SmartDeal
aktiv"), and "Gültig bis" shown unconditionally instead of only when active (panel: "Ersparnis und
Bildmarke werden ausgeblendet" text when off). New `markenlogoUrl()` in `bilder.ts` (points at the
panel's own bundled `shell-logo.svg`, served under the integration's `/audi_dashboard_static/` path
— a genuinely different static route than `/local/bilder/`'s user-uploaded vehicle photos, confirmed
via `const.py`'s `STATIK_URL` vs `BILDER_ORDNER`), with the same graceful `onError`-hide fallback
used for the Tanken-chapter SmartDeal pill. `Feld`'s `label` prop already accepts a `ReactNode`, so
no new component was needed for the inline icon.
**Then, live-verifying those three fixes surfaced something much bigger: `@audi-dash/ui`'s shared
`Switch`, `Seg`, and `Feld` components were wrong in ways that affect every screen in the app that uses
them — not just Einstellungen.** Found by literally looking at the rendered page rather than only
reading JSX:
4. **Every toggle switch in the entire app was red instead of green.** `Switch.css`'s checked state
used `background: var(--red)` — a real "red as a non-destructive state color" violation, the same
category already fixed for the TabBar active pill, the Statistik Arbeitsweg bar segment, and the
back arrow/primary buttons earlier this project, just never caught here because nothing had diffed
`@audi-dash/ui`'s own component CSS against the panel's `.switch input:checked+.track` rule before.
Fixed to `var(--ok)`, matching `audi-dashboard-ios.css` exactly. This one rule affects literally
every switch in the app: "Beschriftung der Tab-Leiste", "Termin vereinbart" (Reifen, built this
session), SmartDeal, etc.
5. **`Seg` (the Nacht/Tag segmented control, used only here so far) drew the active segment with a red
outline ring and rendered all labels uppercase with heavy letter-spacing** — again the pre-iOS-
decision neutral style, same root cause as `.ads-eyebrow`/`.ads-quad__l` earlier this session, just
never carried over to `Seg`. Panel's actual `.seg`/`.seg button`/`.seg button.on` (always the
iOS-overlay values, confirmed live-measuring the token colors) is a filled track with a moving
pill (`background:var(--tile-2)`, `box-shadow`), no red anywhere, sentence case, no letter-spacing.
Rebuilt `Seg.css` to match exactly, and added a new `--ios-fill` token to `tokens.css` (both
themes, live-measured `rgba(120,120,128,.24)` dark / `.16` light) since the track background needed
it and `Feld`'s input boxes turned out to need the identical token (see next point) — a real,
reusable value, not a one-off hardcode.
6. **`Feld` (used by nearly every edit form in the app) had four separate mismatches, only found by
testing a genuinely long label live** ("Pause zwischen zwei Fahrten" collapsed to a stack of
single-word lines squeezing the input to nothing — a real, user-visible layout bug, not just a
color drift):
- `.ads-feld label` was 13px/`var(--fg2)` (the same pre-iOS-decision eyebrow style, again) instead
of the panel's 16px/`var(--fg)` — already fixed once for the read-only `Wertzeile` component in
the MeinAudi chapter, but never carried over to the editable-field component, despite `Feld` being
used far more often.
- The label also lacked `flex: 1 1 auto; min-width: 0` (present in the panel's *base* stylesheet,
not overridden by the iOS overlay, so still in force) — without it, the label competed for space
with the input instead of taking the row's remaining width and wrapping there.
- `.ads-feld input,select` had a hardcoded `min-width: 132px` floor with no matching `flex`/
`max-width` — the panel's own base rule is `min-width:0; max-width:100%; flex:0 1 auto`, letting
the input shrink to make room for the label. The 132px floor was exactly what starved "Pause
zwischen zwei Fahrten" down to 0px width.
- `.ads-mit-einheit input` (the compact number-plus-unit pattern — km/Nm/Minuten fields) had no
explicit `width`, only `min-width: 92px` — the panel's `.mitEinheit input` sets `width: 96px;
min-width: 0` explicitly; without a real width, the browser's unconstrained default input size
(~170190px) dominated, which is what actually produced the "Pause" field's collapse (measured
live: the input+unit group alone consumed 274 of the row's 279px before the fix).
- Also renamed the "Pause zwischen zwei Fahrten"/"Fahrten" label and tile eyebrow to the panel's
actual wording ("Pause bis"/"Fahrten zusammenfassen") while fixing this, since the original
companion-app phrasing was what triggered the multi-line wrap in the first place and the panel's
shorter wording is the actual target text anyway.
**Checked and left alone, deliberately:** the Setup-menu sensor-mapping UI (`vEinst()`'s "Setup —
Sensoren zuordnen" and the whole entity-catalog popup machinery) is an HA-panel-only concept with no
companion-app equivalent needed, per the parity rule's own established carve-out for this exact screen
(sections R/AF); "Nacht beginnt"/"Nacht endet" being editable in companion-app but not in the panel is
a companion-app-only *addition* (the `nacht_von`/`nacht_bis` profile fields exist and are read by both
apps' Statistik screens, but the panel's `vEinst()` never exposed an edit UI for them at all) — additive,
not a gap to remove.
Verified: `npm run typecheck` clean, `npm test` 147/147 at every step, `design-system`'s `npm run build`
rerun after each CSS change, live-verified through the dev proxy against `audi_ha_test` via computed-
style JS queries and screenshots (the Browser pane went in and out of a "hidden" state mid-chapter,
worth remembering: `get_page_text`/`javascript_tool` computed-style queries keep working even when
`computer` screenshots time out) — confirmed the Switch track computes to the real `--ok` RGB, the Seg
active pill's background/box-shadow/color match the panel's token values exactly with no red, the Feld
label renders at 16px/black, the Feld input box uses the real `--ios-fill` RGBA, and — after the two
follow-up width fixes — "Pause bis" renders on a single line with a compact 96px-wide "15" box, matching
`audi-dashboard-ios.css`'s actual rendered layout byte-for-byte instead of just close-enough.
**Chapter 10 is closed.** Next: Chapter 11 (Abschluss) — build, version bump, `npm run ota`, final
report.
## Chapter 11 (Abschluss): build, version bump, OTA rebuild, deployment, and the audit's own summary
(2026-08-30)
**Build/version/OTA:** `npm run build` (production `vite build`) succeeded clean. Manifest bumped
`2026.8.30.1``2026.8.30.2` (companion-app-only round, no panel-side source changed this whole
11-chapter audit). `npm run ota` rebuilt the bundle from the new manifest version automatically:
`bundle.zip` 248,241 bytes, sha256 `88922cb0e3a7603b6e4e2fe7daee85513ec9e16c4ae7b2e0229f514bde58915f`.
**Deployment hit a real, worth-remembering `docker cp` gotcha, distinct from the already-documented
Git-Bash path-mangling one.** After correcting the path issue (`MSYS_NO_PATHCONV=1`, per section AB's
existing note), the manifest inside the container still read the old version. Root cause:
`docker cp local_dir container:/existing/dir` does **not** overwrite the contents of an
already-existing destination directory — it copies `local_dir` *into* it as a nested subdirectory
(`/config/custom_components/audi_dashboard/audi_dashboard/...`), leaving the real top-level
`manifest.json` untouched. Confirmed by listing the container's folder and finding the stray nested
copy. Fixed by removing the nested duplicate and re-copying with a trailing `/.` on the source
(`docker cp local_dir/. container:/dest_dir`, the standard "copy contents, not the folder itself"
idiom) — this is the general rule going forward for this project: **`docker cp` into an existing
integration folder always needs the trailing `/.` on the source**, not just the `MSYS_NO_PATHCONV=1`
env var. Also cleared `__pycache__/*.pyc` before the final restart as a defensive measure (not
strictly proven necessary this round, but cheap insurance against stale bytecode). Verified via
`docker logs --tail 15`: single `audi_dashboard` loader warning, `Audi Dashboard 2026.8.30.2
eingerichtet`, no traceback.
### Full recap of this audit (chapters 111, one continuous pass, 2026-08-30)
Triggered by the owner's report that a build compiled by Xcode still looked stale/inconsistent
against the panel, and the instruction to check *everything* — font, size, weight, line-height,
letter-spacing, color, icon, and structure — not just spot-check. Went screen-group by screen-group:
1. **Übersicht** — wrong color-token source (base stylesheet instead of the always-loaded iOS
overlay, the single biggest individual color fix), `.ads-title`/`.ads-fig` letter-spacing/
line-height, the "Mein Audi" tab icon placeholder, badge-zusatz text styling, Reichweite tile
layout order, teaser-tile hero-figure sizing, alignment, `.dm-servicefig` spacing, the tab bar's
red active-indicator (should be a neutral pill), the Standort feature built from scratch (it
didn't exist in companion-app at all), a `.dm-szene` grey background that shouldn't be there, an
oversized vehicle image aspect ratio, and a Standort-fullscreen height-cap bug.
2. **Mein Audi** — a full structural rebuild (was a static photo grid + placeholder-text tiles;
became a cycling image + five real-data preview tiles), plus the systemic `Wertzeile` dt/dd
color reversal (affects every "field: value" row app-wide).
3. **Service/Reifen** — Service.tsx mostly already correct; Reifen.tsx needed a full structural
rebuild (both tire sets shown at once instead of a Seg toggle, separate Anzugsmoment/Wechseltermin
tiles, previously read-only Hersteller/Modell/Größe/DOT/Solldruck/Kommentar fields made editable
to match the panel's live-save fields) — this was the chapter re-opened mid-audit by a direct
owner complaint ("reifen sieht völlig unterschiedlich aus").
4. **Versicherung/Steuer** — a real `[object Object]` rendering bug in `Schutzbrief()` from three
mis-typed fields, rebuilt against the real nested data shapes.
5. **Sicherheit** — missing Draufsicht image, wrong top-status indicator component, an extra
timestamp line not in the panel, and a `TuerenKlappen.tsx` detail list using the wrong dot
component/row structure.
6. **Fahrten** — trip list rows missing the route text entirely, detail hero figure at the wrong
size, map start/end markers visually indistinguishable (both red instead of black-vs-red).
7. **Tanken** — a real broken-string plural bug ("Tankvorgang" + a stray combining diaeresis),
missing accordion secondary lines, a completely unimplemented SmartDeal feature (logo, Ersparnis
field, Kraftstoff selector), and `TankDetail.tsx` having no editing capability at all.
8. **Statistik** — one real typography bug (`StatGrid` caption using the pre-iOS-decision uppercase
eyebrow style).
9. **Batterie** — the voltage chart's line and dots were flat red instead of the panel's
neutral-line/health-color-coded-dots scheme (a real, if quieter, instance of the same "red as a
non-destructive data color" class of bug found repeatedly this session).
10. **Einstellungen/Datensatz** — three real content gaps (no backup-file import, no installed-version
display, SmartDeal tile missing its logo/conditional field) that, while verifying them live,
surfaced **four separate app-wide bugs in `@audi-dash/ui` itself** (`Switch` red-instead-of-green,
`Seg` red-ring-instead-of-filled-pill, and `Feld`'s label color/size and its input width/flex
handling) — the single highest-leverage fixes of the whole audit, since `Switch` and `Feld` are
used by nearly every edit screen in the app.
**The recurring lesson across all eleven chapters, worth stating plainly for the next session:**
almost every real bug fell into one of three buckets — (a) reading only the *base* panel stylesheet
instead of the always-loaded iOS overlay that overrides most of it, (b) `@audi-dash/ui` still
shipping its pre-2026-08-13 neutral/uppercase/red-accented styling in components nobody had re-diffed
against the panel's actual iOS-decision values, and (c) genuine structural/content gaps where a
companion-app screen was built once, early, and never revisited as the panel kept gaining features.
None of these were caught by `tsc`/`vitest` — every one needed an actual live render, often down to
`getComputedStyle` queries, to surface.
**Known, deliberately-not-fixed items, carried forward for a future session (flagged, not silently
dropped):**
- `.dm-fussnote`'s cross-cutting 35-site typography ambiguity (Service.tsx footnote size/color vs.
its many other uses) — flagged in Chapter 3, never resolved, still open.
- `Versicherung.tsx`'s top-level `vVers()` structure (separate Notruf/Jahresbeitrag-bar/Vertrag
tiles with their own edit gears) was never rebuilt 1:1 — companion-app kept its single-summary-tile
+ `NaviKachel`-grid structure. `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were never
individually re-verified against their panel counterparts this audit.
- `LiveFahrt.tsx` has no panel counterpart to diff against at all (a companion-app-only screen,
explicitly built-but-disabled pending real FMM003 live data) — untouched, correctly out of scope.
- The unreproduced satellite/map-layer-icon complaint from early in this session (checked three
times via markup, function, and computed paint values — never found a defect) remains open pending
a real device screenshot from the owner.
- A `Feld`-label reachability report ("Batteriespannung row not clickable on desktop") from earlier
this project could not be reproduced via any hit-testing method tried — flagged, not resolved.
Verified end to end: `npm run typecheck` clean and `npm test` 147/147 after every single change this
entire audit (never left red), `npm run build` clean, `design-system`'s own `npm run build` rerun
after every CSS-only fix, live-verified through the dev proxy against `audi_ha_test` at real mobile
viewport width for every chapter, manifest bumped to `2026.8.30.2`, OTA bundle rebuilt and matching,
integration redeployed and confirmed via a clean single-warning setup log.
## Chapter 2 (Mein Audi): a structural rebuild, not a styling pass — plus the second-biggest systemic typography find of the audit (2026-08-30)
Owner: "mache mit 2 bis 11 weiter" (continue with chapters 211).
**`MeinAudi.tsx` was not a styling-drift case — it was a fundamentally different, much poorer page than `vAudi()`.** The panel's version: a single cyclable vehicle photo (`carImg("galerie")`, tap-to-advance, dot indicator), a flat badge/Kennzeichen row, then five tiles each showing a **real, multi-row data preview** (Fahrgestellnummer/Details/Erstzulassung/Ausführung/Ausstattung; Kilometerstand/Tankfüllung/Batteriespannung; the actual next-service dates; Versicherung/Steuer/Summe amounts; Reifen montiert/Laufleistung/Wechseltermin) — every tile a button leading to its detail screen, chevron included. companion-app's version: a static grid of *all seven* photo slots (a concept that belongs to Einstellungen → Bild der Übersicht, not here) plus five generic `NaviKachel` "go" tiles, four of which showed **hardcoded placeholder sentences** ("Termine und Wartungsplan", "Beitrag, Vertrag, Kfz-Steuer") instead of any real value. Rebuilt from scratch to match the panel's structure: cycling image + dots (new local state, `BILDPLAETZE` index), flat badge/Kennzeichen tile (same pattern just fixed on Übersicht), and five `Tile variant="button" chevron` tiles each with a real `Werteliste`/`Wertzeile` data preview sourced from the same fields the panel reads (`fahrzeug.fin/details/erstzulassung`, `ausstattung` position count via the same `AusstattungGruppe`/`sonder.length` cast already used in `Fahrzeugdaten.tsx`, `fahrzeug.versicherung["beitrag"]`/`steuer["betrag"/"zeitraum"]`, `fahrzeug.reifen.wechsel`).
**One deliberate, documented scope reduction:** the panel's "Service" tile embeds its *entire* three-term list (Ölwechsel/Inspektion/Hauptuntersuchung, with all the meldung-vs-Servicebuch fallback logic that lives in `Service.tsx`). Reproducing that exact logic a second time here would have meant duplicating a large, delicate block of conditional logic. Used the existing `naechsterService()` instead (the same single-nearest-term function that already feeds the Übersicht tile) — a real, acknowledged simplification, not a silent omission; flagged here and in the component's own header comment. Worth extracting `Service.tsx`'s three-term computation into a shared, reusable function later if the owner wants the full breakdown here too.
**Systemic find, while wiring the new tiles' data rows: `Wertzeile`/`.dm-wertzeile` (`dt`/`dd`, used by every "field: value" row across the *entire* app — `Fahrzeugdaten.tsx`, `Reifen.tsx`, `Versicherung.tsx`, `TankDetail.tsx`, `FahrtDetail.tsx`, now `MeinAudi.tsx`, and more) had its typography backwards.** Live-measured the panel's real `.row dt`/`.row dd` (careful this time to check the iOS-overlay file, not just the base stylesheet, after getting burned by that exact gap twice already this session): **both 16px/weight 400**`dt` (the field label) in `var(--fg)`, full-strength black/white; `dd` (the actual value) in `var(--fg2)`, the secondary tone. companion-app had it backwards and smaller: `dt` 13px/300/`var(--fg2)`, `dd` 14px/300/unset(≈`fg`) — label small and muted, value slightly bigger and full-strength, the opposite hierarchy from the panel's own convention. Fixed centrally in `.dm-wertzeile dt`/`dd` (screens.css) — one fix, reaches every screen that uses `Wertzeile`, not just this one. Also corrected the `<small>` zusatz caption size (`.dm-wertzeile small`/`.dm-navikachel__wert small`): `11.5px``12.5px` (live-measured `.row dd small`), and `.dm-wertzeile`'s own row padding `10px``11px` (matching `.row{padding:11px 0}`).
New CSS added for the rebuilt structure: `.dm-galerie-wechsel`/`.dm-galerie-punkte` (the cycling image + dots, mirroring `.dots` 1:1), `.dm-wertzeile--knopf`/`.dm-wertzeile__chevron` (the one clickable row — Batteriespannung → Battery screen — matching the panel's `.row[data-go]` pattern), and a scoped `.dm-galerie-wechsel .ads-bildbox{aspect-ratio:16/9}` (the panel's plain `"carfix"` class, not the `"carfix klein"` 16:8 used only on Übersicht's fixed image — confirmed by reading `carImg("galerie")`'s own call site, not assumed to match Übersicht's image).
Verified: `npm run typecheck` clean, `npm test` 147/147 (existing smoke test renders the new structure without throwing). Live-verified via screenshot at true mobile width: cycling image with 7 dots (first active/red), badge+Kennzeichen flush left matching the now-fixed Übersicht pattern, all five tiles showing real data with correctly black-label/grey-value rows and visible chevrons, Batteriespannung row showing a real live value (13,6 V) with its own chevron.
## Chapter 3 (Service & Reifen): mostly solid already, two real missing features found (2026-08-30)
Owner picked option 1 ("gleiches Tempo/Tiefe beibehalten") after a mid-audit status check — continuing at full depth across sessions rather than switching to a lighter pass.
**`Service.tsx`'s "Anstehende Termine" tile needed almost no work** — 4 of 5 live-measured elements (Herstellervorgabe, Prognose-Fußzeile, Leerzustand-Text, Eyebrow) already matched the panel exactly, entirely as a side effect of this session's earlier systemic fixes (color tokens, `.tg-wert`'s Wide font). Only `.dm-fussnote` (the explanatory paragraph) differs (panel: 13.5px/`var(--fg2)`; companion: 11.5px/`var(--fg3)`) — but that class is used **35 times across 13 files** for many different kinds of captions, not a 1:1 match to any single panel class. Left alone rather than risking 34 other call sites on one owner report; flagged for a future targeted look, not a blind global change.
**`Reifen.tsx`'s overall structure (a `Seg` toggle between Sommer/Winter instead of the panel's two-tiles-at-once layout) is a legitimate idiom difference, not a gap** — same information reachable, matches this project's own "different idiom, same content" allowance. But checking it surfaced **two genuinely missing features, not styling**: the panel lets the owner correct the **Anzugsmoment** (torque spec) and the **active tire set's own odometer** via a small pencil-icon edit, both wired to real backend writes (`profilSpeichern()` for Anzugsmoment; the dedicated `reifen_km_setzen` service for km, specifically *not* through `profilSpeichern()` — see `reifenzaehler.py`'s own reasoning, already documented elsewhere in this file, about not clobbering `referenz_odo_km`). Neither existed anywhere in companion-app — confirmed by grepping for `reifen_km_setzen`/`reifenKmSetzen` across `api/` and `screens/` (zero hits) and for `anzugsmoment` outside `Reifen.tsx` itself (only the one, read-only usage).
**Built, following this codebase's own established edit-toggle idiom** (matching `ArchivZeile`'s own pattern in the same file, not the panel's inline-always-editable fields):
- `api.reifenKmSetzen(satz, km)` (new, `api/index.ts`) — same `warteschlange.einreihen(DIENST_DOMAIN, "reifen_km_setzen", {satz, km})` shape as the existing `reifenArchivieren`.
- `SymbolEdit` (new, `symbole.tsx`) — the panel's `edit-s` CI icon, verbatim, following the same `RahmenFlaeche` pattern as `SymbolOelwechsel`/`SymbolInspektion` (a filled icon, not a stroke outline).
- Two pencil-icon-triggered inline edit panels in `Reifen.tsx`: Anzugsmoment (writes via `profilSpeichern`) and the currently-displayed tire set's km (writes via the new `api.reifenKmSetzen`, respecting the `Seg` toggle's `anzeige` state — correctly targets whichever set, sommer or winter, is on screen). New shared `.dm-bearbeiten-block` CSS for the expandable edit-panel container (border-top + spacing, matching the panel's own inline-edit-block treatment).
Verified: `npm run typecheck` clean, `npm test` 147/147. Live-tested both edit flows end-to-end through the dev proxy against `audi_ha_test` (not just rendered — actually opened each panel, confirmed the input pre-fills with the real current value: `120` Nm, `187992` km) — cancelled both without saving, so no real data was changed in the test container.
## Chapter 4 (Versicherung & Steuer): a real `[object Object]` rendering bug in `Schutzbrief()` (2026-08-30)
**`Schutzbrief()` in `Versicherung.tsx` mis-typed three of its five data lists as plain `string[]`, when the actual profile data is nested objects/tuples — the panel's own `sb.entweder`/`sb.gruppen`/`sb.nicht` were never string arrays.** Concretely: `entweder` is `{bez, grenze, zusatz}[]`, `gruppen` is `{titel, posten: [bez,wert,zusatz][]}[]`, `nicht` is `[bez, begründung][]`. The old code cast all three to `string[]` and rendered `<li>{eintrag}</li>` directly — for `entweder`/`gruppen` this would have printed literal `[object Object]` for every entry (confirmed this really was the bug, not a hypothetical: reproduced the *old* code's behavior mentally against the real profile data structure, which is an object array, not strings), and for `nicht` (an array of 2-tuples) would have shown something like the array's default `toString()` join rather than readable text. Found by reading the panel's actual `vSchutz()` markup line by line instead of assuming the existing companion-app types were correct — the same lesson as several earlier findings this audit: check the real data shape, don't trust an existing cast.
Rebuilt to match each field's real shape and the panel's own grouping: `entweder` → its own tile with `Wertzeile`s (bez/grenze/zusatz) plus the fixed "Mobilität wird binnen 60 Minuten organisiert" note (the panel shows this unconditionally whenever `entweder` has content, not behind some other flag — the previous code's structure had already lost this line entirely); `ausloeser` → its own tile with `gilt`/`versichert` as trailing footnotes (matching the panel's placement exactly, previously merged into a single made-up "summary" tile that doesn't exist in the panel); `gruppen`**one tile per group**, each its own `Wertzeile` list (previously flattened into a single wrong list); `nicht``Wertzeile`s with the reason as the `zusatz` caption; `pflichten` → its own tile with `rang`/`stand` as trailing footnotes (again matching the panel's actual placement, not a separate summary tile).
Verified: `npm run typecheck` clean, `npm test` 147/147. Live-verified through the dev proxy against `audi_ha_test`'s real profile data — confirmed **zero** `[object Object]` anywhere on the rendered page (checked via `document.body.innerText.includes('[object Object]')`, not just eyeballing) and real, correctly formatted insurance terms showing throughout ("Weiterkommen: Bahn 1. Klasse bis 1 200 km…", "Am Schadenort: Pannenhilfe vor Ort bis 100 €…").
**Not yet done in this chapter** (context budget — flagged, not silently skipped): `vVers()`'s top-level structure (separate Notruf/Jahresbeitrag-with-breakdown-bar/Vertrag tiles with edit gears) vs. companion's single combined summary tile + `NaviKachel` grid was not rebuilt to match 1:1, unlike the `MeinAudi.tsx` rebuild in Chapter 2 — `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were not individually re-verified against their panel counterparts (`vBeitrag()`/`vVertragBearbeiten()`/`vNotrufBearbeiten()`/`vSteuer()`) this round. Worth a dedicated pass later.
--- ---
## Working conventions (observed — keep them) ## Working conventions (observed — keep them)
+22
View File
@@ -43,6 +43,28 @@ ARCHIV="$AUSGABE/DataMetric360.xcarchive"
cd "$APP" 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" echo "==> Webbuendel bauen"
npm run build npm run build
+1 -1
View File
@@ -65,7 +65,7 @@ describe("Shell", () => {
expect(bereichVon("trip")).toBe("trips") 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") const geheZu = rahmen("home")
fireEvent.click(screen.getByLabelText("Einstellungen")) fireEvent.click(screen.getByLabelText("Einstellungen"))
expect(geheZu).toHaveBeenCalledWith("einst") 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> <span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div> <div className="ads-title">{TITEL[route.name]}</div>
</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 <button
type="button" type="button"
className="dm-kopf__aktion" className="dm-kopf__aktion"
@@ -71,7 +79,7 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
aria-current={route.name === "einst" ? "page" : undefined} aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")} onClick={() => geheZu("einst")}
> >
<SymbolZahnrad groesse={22} /> <VierRinge hoehe={17} />
</button> </button>
</header> </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> { reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
return this.warteschlange.einreihen( return this.warteschlange.einreihen(
DIENST_DOMAIN, DIENST_DOMAIN,
+8
View File
@@ -47,6 +47,13 @@ export interface DatenWert {
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert. /** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
Siehe appVersion.ts und VERSIONIERUNG.md. */ Siehe appVersion.ts und VERSIONIERUNG.md. */
versionsstand: Versionsstand 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), /** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
Verbindung. */ Verbindung. */
@@ -226,6 +233,7 @@ export function DatenAnbieter({
warteschlange, warteschlange,
statusStand, statusStand,
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand, versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
serverVersion,
otaBuendel, otaBuendel,
integrationUpdate, integrationUpdate,
einstellungen, einstellungen,
+2
View File
@@ -60,6 +60,7 @@ export interface Fahrzeug {
odo: number odo: number
odoBekannt: boolean odoBekannt: boolean
tankPct: number tankPct: number
tankBekannt: boolean
reichweite: number | null reichweite: number | null
batteriespannung: number | null batteriespannung: number | null
gesichert: boolean | null gesichert: boolean | null
@@ -164,6 +165,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
odo: status.km ?? 0, odo: status.km ?? 0,
odoBekannt: status.km !== null && status.km !== undefined, odoBekannt: status.km !== null && status.km !== undefined,
tankPct: status.tankprozent ?? 0, tankPct: status.tankprozent ?? 0,
tankBekannt: status.tankprozent !== null && status.tankprozent !== undefined,
reichweite: status.reichweite_km ?? null, reichweite: status.reichweite_km ?? null,
batteriespannung: status.batteriespannung ?? null, batteriespannung: status.batteriespannung ?? null,
gesichert: status.gesichert ?? null, gesichert: status.gesichert ?? null,
+1
View File
@@ -21,6 +21,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
odo, odo,
odoBekannt: true, odoBekannt: true,
tankPct: 0, tankPct: 0,
tankBekannt: true,
reichweite: null, reichweite: null,
batteriespannung: null, batteriespannung: null,
gesichert: null, gesichert: null,
+3
View File
@@ -35,6 +35,7 @@ export type SeitenName =
// Sonstige // Sonstige
| "sicherheit" | "sicherheit"
| "tuerenklappen" | "tuerenklappen"
| "standort"
| "live" | "live"
| "einst" | "einst"
@@ -68,6 +69,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
einst: "home", einst: "home",
sicherheit: "home", sicherheit: "home",
tuerenklappen: "sicherheit", tuerenklappen: "sicherheit",
standort: "home",
live: "home", live: "home",
} }
@@ -96,6 +98,7 @@ export const TITEL: Record<SeitenName, string> = {
fill: "Tankvorgang", fill: "Tankvorgang",
sicherheit: "Fahrzeugstatus", sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen", tuerenklappen: "Türen und Klappen",
standort: "Standort",
live: "Fahrt läuft", live: "Fahrt läuft",
einst: "Einstellungen", 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], [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 { function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null if (spannung > AGM_RUHE_MAX_V) return null
const t = LADEKURVE const t = LADEKURVE
@@ -230,6 +272,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
const stufen = farbstufenLesen()
return ( return (
<> <>
@@ -241,7 +284,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
aria-label={`Spannungsverlauf über ${werte.length} Messungen`} aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen} 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) => {werte.map((t, i) =>
t.min == null ? null : ( t.min == null ? null : (
<circle <circle
@@ -249,7 +292,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
cx={x(i)} cx={x(i)}
cy={y(t.min)} cy={y(t.min)}
r={gewaehlt === i ? 4.5 : 2.4} r={gewaehlt === i ? 4.5 : 2.4}
fill="var(--red)" fill={farbeFuerSpannung(t.min, stufen)}
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})} {...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
/> />
), ),
+117 -24
View File
@@ -4,7 +4,7 @@
* Datenausgabe und Zugang. * Datenausgabe und Zugang.
*/ */
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui" 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 { useTheme } from "../theme"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder" import { BILDPLAETZE, markenlogoUrl } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup" import { DatensatzPopup } from "./DatensatzPopup"
import { HistorienImport } from "./HistorienImport" import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service" import type { ServicebuchEintrag } from "./Service"
@@ -41,6 +41,7 @@ export function Einstellungen({
rohprofil, rohprofil,
otaBuendel, otaBuendel,
integrationUpdate, integrationUpdate,
serverVersion,
verbindung, verbindung,
api, api,
profilSpeichern, profilSpeichern,
@@ -59,6 +60,9 @@ export function Einstellungen({
const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false) const [updateNeustartLaeuft, setzeUpdateNeustartLaeuft] = useState(false)
const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null) const [updateAktionFehler, setzeUpdateAktionFehler] = useState<string | null>(null)
const [datensatzPopup, setzeDatensatzPopup] = useState<"sichern" | "laden" | 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()) const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
@@ -189,6 +193,39 @@ export function Einstellungen({
await neuLaden() 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 profilExportieren = () => {
const inhalt = JSON.stringify( const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() }, { profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
@@ -240,8 +277,12 @@ export function Einstellungen({
</Tile> </Tile>
<Tile> <Tile>
<span className="ads-eyebrow">Fahrten</span> {/* Panel-Wortlaut: "Fahrten zusammenfassen" / "Pause bis" - war hier
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten"> 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 <input
className="dm-eingabe" className="dm-eingabe"
inputMode="numeric" inputMode="numeric"
@@ -327,7 +368,22 @@ export function Einstellungen({
<Tile> <Tile>
<span className="ads-eyebrow">Tankrabatt</span> <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 <Switch
checked={fahrzeug.smartdeal.aktiv} checked={fahrzeug.smartdeal.aktiv}
onChange={(an) => onChange={(an) =>
@@ -341,25 +397,31 @@ export function Einstellungen({
aria-label="Tankrabatt aktiv" aria-label="Tankrabatt aktiv"
/> />
</Feld> </Feld>
<Feld label="Gültig bis" last> {fahrzeug.smartdeal.aktiv ? (
<input <>
className="dm-eingabe" <Feld label="Gültig bis" last>
type="date" <input
value={fahrzeug.smartdeal.laeuftAb ?? ""} className="dm-eingabe"
onChange={(e) => type="date"
void profilSpeichern({ value={fahrzeug.smartdeal.laeuftAb ?? ""}
fahrzeug: { onChange={(e) =>
...fahrzeug, void profilSpeichern({
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null }, 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 </Feld>
schaltet er ab. <p className="dm-fussnote">
</p> 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> </Tile>
{/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor {/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor
@@ -430,7 +492,25 @@ export function Einstellungen({
Jetzt sichern Jetzt sichern
</ActionButton> </ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</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> </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>
<Tile> <Tile>
@@ -485,6 +565,19 @@ export function Einstellungen({
</Tile> </Tile>
)} )}
<Tile>
<span className="ads-eyebrow">Version</span>
<Werteliste
kinder={
<Wertzeile
label="Installiert"
wert={serverVersion ?? "unbekannt"}
zusatz="Home-Assistant-Integration"
/>
}
/>
</Tile>
<Tile> <Tile>
<span className="ads-eyebrow">Integration-Update</span> <span className="ads-eyebrow">Integration-Update</span>
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt {/* 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> </button>
</div> </div>
<div className="dm-detail__kopf"> <div className="dm-detail__kopf">
<div className="dm-detail__zahl"> <div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(fahrt.distance_km, 1)} {deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span> <span className="dm-detail__einheit">km</span>
</div> </div>
+13 -1
View File
@@ -16,6 +16,18 @@ import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from ".
import type { FahrtFelderEingabe } from "./FahrtFelder" import type { FahrtFelderEingabe } from "./FahrtFelder"
import { Zeilenmenue } from "./Zeilenmenue" 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 }) { export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrten, api } = useDaten() const { fahrten, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false) const [formularOffen, setzeFormularOffen] = useState(false)
@@ -80,7 +92,7 @@ export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
{deOderStrich(fahrt.distance_km, 1)} km {deOderStrich(fahrt.distance_km, 1)} km
</span> </span>
<span className="dm-listenzeile__unter"> <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)} {dauer(fahrt.duration_s)}
</span> </span>
</span> </span>
+23 -14
View File
@@ -11,15 +11,15 @@
* Motorhaube und dem Kofferraum einzeln. * Motorhaube und dem Kofferraum einzeln.
*/ */
import { StatusRow, Tile } from "@audi-dash/ui" import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import { datumZeit } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine" import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine"
import { Bild } from "./Bild"
export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) { export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { fahrzeug, statusStand } = useDaten() const { fahrzeug } = useDaten()
if (!fahrzeug) return null if (!fahrzeug) return null
const punkte = fahrzeug.sicherheitscheck 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 tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen"))
const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach")) const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach"))
const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht")) 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 ( return (
<> <>
<StatusRow {/* Draufsicht, wie im Panel (.szene.bleed, "carfix klein" = 16:8) - war
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"} hier komplett unportiert (Parität-Audit 2026-08-30). */}
title={ <div className="dm-szene">
fahrzeug.gesichert === true <Bild datei="draufsicht.webp" beschreibung="Draufsicht" name="Draufsicht" />
? "Fahrzeug ist sicher abgestellt" </div>
: fahrzeug.gesichert === false
? "Bitte prüfen" {/* Deckungsgleich mit der obersten Kachel in vSicherheit(): dasselbe
: "Zustand nicht vollständig bekannt" Kreis-Häkchen-Symbol wie die vier Zeilen unten (nicht der generische
} StatusRow-Punkt), keine Zeitangabe - das Panel zeigt dort keine. */}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})} <Tile className="dm-sicherheitssammel">
/> <StatusKreis ok={gesichert} />
<span>{sammelText}</span>
</Tile>
<Tile> <Tile>
<div className="dm-sicherheitsliste"> <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) L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte)
const punkte: [number, number][] = [[start.lat, start.lon]] 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") .bindTooltip("Start")
.addTo(karte) .addTo(karte)
if (ziel) { if (ziel) {
punkte.push([ziel.lat, ziel.lon]) 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") .bindTooltip("Ziel")
.addTo(karte) .addTo(karte)
// Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen // Echte Route, wenn vorhanden - sonst eine Verbindungslinie NUR zwischen
// den beiden echten Punkten, kein erfundener Verlauf dazwischen. // den beiden echten Punkten, kein erfundener Verlauf dazwischen.
const linie = route && route.length >= 2 ? route : punkte 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) 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) punkte.push(...route) 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) { if (punkte.length > 1) {
+181 -44
View File
@@ -1,72 +1,209 @@
/** /**
* „Mein Audi" — Sammelseite mit Fotogalerie und Wegen zu den Unterseiten. * „Mein Audi" — Sammelseite mit Fahrzeugbild, Kennzeichen und den Wegen zu
* Vorlage: `vAudi()` im alten Panel. * 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 { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext" 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 type { SeitenName } from "../navigation"
import { NaviKachel } from "./bausteine" import { Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder" 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 }) { 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 if (!fahrzeug || !einstellungen) return null
const aktiverSatz = const aktiverSatz =
fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer 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 ( return (
<> <>
<Tile> <button
<span className="ads-eyebrow">Fotogalerie</span> type="button"
<div className="dm-galerie"> className="dm-galerie-wechsel"
{BILDPLAETZE.map((platz) => ( onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)}
<Bild aria-label="Nächstes Bild"
key={platz.datei} >
datei={platz.datei} <Bild datei={platz.datei} beschreibung={platz.beschreibung} name={platz.name} />
beschreibung={platz.beschreibung} </button>
name={platz.name} <div className="dm-galerie-punkte" aria-hidden="true">
groesse="mini" {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> </div>
<p className="dm-fussnote"> <span className="ads-eyebrow">
Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten {einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
Dateinamen, damit klar ist, was wohin gehört. </span>
</p>
</Tile> </Tile>
<div className="dm-raster"> <Tile variant="button" chevron onClick={() => geheZu("ident")}>
<NaviKachel <span className="ads-eyebrow">Fahrzeug</span>
titel="Fahrzeugdaten" <Werteliste
wert={fahrzeug.fin || "Technik und Ausstattung"} kinder={
beiKlick={() => geheZu("ident")} <>
/> <Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
<NaviKachel <Wertzeile label="Details" wert={fahrzeug.details || "—"} />
titel="Batterie" <Wertzeile label="Erstzulassung" wert={fahrzeug.erstzulassung || "—"} />
wert={ <Wertzeile label="Ausführung" wert={ausfuehrung || "noch offen"} />
fahrzeug.batteriespannung != null <Wertzeile label="Ausstattung" wert={`${positionen} Positionen`} />
? `${de(fahrzeug.batteriespannung, 2)} V` </>
: "Kein Spannungssensor"
} }
beiKlick={() => geheZu("battverlauf")}
/> />
<NaviKachel titel="Service" wert="Termine und Wartungsplan" beiKlick={() => geheZu("service")} /> </Tile>
<NaviKachel
titel="Reifen" <Tile>
wert={`${fahrzeug.reifen.aktiv} · ${deOderStrich(aktiverSatz.km)} km`} <span className="ads-eyebrow">Zustand</span>
beiKlick={() => geheZu("reifen")} <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 </Tile>
titel="Versicherung & Steuer"
wert="Beitrag, Vertrag, Kfz-Steuer" <Tile variant="button" chevron onClick={() => geheZu("service")}>
beiKlick={() => geheZu("vers")} <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 { 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 { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter" import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format" import { datum, de, deOderStrich } from "../format"
import { SymbolPaket } from "../symbole" import { SymbolEdit, SymbolPaket } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender" import { kalenderDateiLaden } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
export function Reifen() { export function Reifen() {
const { fahrzeug, api, profilSpeichern } = useDaten() const { fahrzeug, api, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false) 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 if (!fahrzeug) return null
const aktiv = fahrzeug.reifen.aktiv 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 } 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 () => { // Deckungsgleich mit data-nmspeichern im Panel: normales Profilfeld, kein
const ziel = aktiv === "Sommer" ? "winter" : "sommer" // eigener Dienst (anders als der Kilometerstand unten).
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return 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) setzeLaeuft(true)
try { try {
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel.toLowerCase() })
// 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 })
} finally { } finally {
setzeLaeuft(false) setzeLaeuft(false)
} }
} }
const archivieren = async () => { const archivieren = async (satzSchluessel: SatzSchluessel) => {
if (!bestaetigen("Neue Räder anlegen?")) return if (!bestaetigen("Neue Räder anlegen?")) return
setzeLaeuft(true) setzeLaeuft(true)
try { try {
// Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand // Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand
// und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar // und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar
// leer - siehe reifen.py archivieren()). // leer - siehe reifen.py archivieren()).
await api.reifenArchivieren(anzeige === "Winter" ? "winter" : "sommer") await api.reifenArchivieren(satzSchluessel)
} finally { } finally {
setzeLaeuft(false) 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({ await profilSpeichern({
fahrzeug: { fahrzeug: { ...fahrzeug, reifen: { ...fahrzeug.reifen, [satzSchluessel]: { ...bisher, [feld]: wert } } },
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
},
}) })
} }
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 ( return (
<> <>
<Tile> <Tile>
<span className="ads-eyebrow">Montiert</span> <span className="ads-eyebrow">Anzugsmoment der Radschrauben</span>
<div className="dm-detail__kopf"> <div className="dm-detail__kopf">
<div className="dm-detail__zahl">{aktiv}</div> <div className="ads-fig" style={{ fontSize: 34, marginTop: 12 }}>
<ActionButton onClick={() => void wechseln()} disabled={laeuft}> {fahrzeug.reifen.anzugsmoment != null ? de(fahrzeug.reifen.anzugsmoment) : ""}
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`} <span className="ads-fig__unit">Nm</span>
</ActionButton> </div>
<IconButton
aria-label="Anzugsmoment ändern"
onClick={() => {
setzeNmEntwurf(String(fahrzeug.reifen.anzugsmoment ?? ""))
setzeNmOffen((o) => !o)
}}
>
<SymbolEdit groesse={16} />
</IconButton>
</div> </div>
<Werteliste {nmOffen && (
kinder={ <div className="dm-bearbeiten-block">
<> <Feld label="Neuer Wert" unit="Nm" last>
<Wertzeile <input
label="Anzugsmoment der Radschrauben" className="dm-eingabe"
wert={ type="number"
fahrzeug.reifen.anzugsmoment != null step={5}
? `${de(fahrzeug.reifen.anzugsmoment)} Nm` min={0}
: "—" value={nmEntwurf}
} onChange={(e) => setzeNmEntwurf(e.target.value)}
/> />
<Wertzeile </Feld>
label="Nächster Wechseltermin" <div className="dm-knopfreihe">
wert={wechsel.datum ? datum(wechsel.datum) : "—"} <ActionButton onClick={() => void anzugsmomentSpeichern()}>Speichern</ActionButton>
/> <ActionButton onClick={() => setzeNmOffen(false)}>Abbrechen</ActionButton>
</> </div>
}
/>
<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>
</div> </div>
)} )}
</Tile> </Tile>
<Seg
options={[
{ value: "Sommer", label: "Sommer" },
{ value: "Winter", label: "Winter" },
]}
value={anzeige}
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
/>
<Tile> <Tile>
<span className="ads-eyebrow"> <span className="ads-eyebrow">Wechseltermin</span>
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"} <Feld label="Geplant am">
</span> <input
<Bild className="dm-eingabe"
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"} type="date"
beschreibung={`${anzeige}rad`} value={wechselDatumAnzeige}
name={`${anzeige}rad`} onChange={(e) => setzeWechselDatum(e.target.value)}
/> />
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: -6 }}> </Feld>
<IconButton aria-label="Neue Räder anlegen" onClick={() => void archivieren()} disabled={laeuft}> <Feld label="Termin vereinbart" last>
<SymbolPaket groesse={18} /> <Switch
</IconButton> 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> </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> </Tile>
{satzTile("sommer")}
{satzTile("winter")}
{fahrzeug.reifen.archiv.length > 0 && ( {fahrzeug.reifen.archiv.length > 0 && (
<Tile> <Tile>
<span className="ads-eyebrow">Archiv</span> <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 { 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 { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext" import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de, deOderStrich, eur } from "../format" import { datum, datumZeit, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" import { KRAFTSTOFFSORTEN, Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface BelegErgebnis { interface BelegErgebnis {
ok?: boolean ok?: boolean
@@ -27,6 +28,7 @@ interface BelegErgebnis {
export function TankDetail({ id }: { id: string | undefined }) { export function TankDetail({ id }: { id: string | undefined }) {
const { tankvorgaenge, api } = useDaten() const { tankvorgaenge, api } = useDaten()
const tank = tankvorgaenge.find((t) => t.tank_id === id) const tank = tankvorgaenge.find((t) => t.tank_id === id)
const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false)
if (!tank) { if (!tank) {
return ( return (
@@ -37,12 +39,21 @@ export function TankDetail({ id }: { id: string | undefined }) {
) )
} }
if (bearbeitenOffen) {
return <TankBearbeiten tank={tank} beiFertig={() => setzeBearbeitenOffen(false)} />
}
return ( return (
<> <>
<Tile> <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__kopf">
<div className="dm-detail__zahl"> <div className="dm-detail__zahl dm-detail__zahl--gross">
{deOderStrich(tank.liters, 2)} {deOderStrich(tank.liters, 2)}
<span className="dm-detail__einheit">l</span> <span className="dm-detail__einheit">l</span>
</div> </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({ function BelegBereich({
tankId, tankId,
belegDatei, belegDatei,
+78 -8
View File
@@ -14,14 +14,16 @@ import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik" import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format" import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation" import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine" import { KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
import { markenlogoUrl } from "./bilder"
import { Zeilenmenue } from "./Zeilenmenue" import { Zeilenmenue } from "./Zeilenmenue"
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { 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 [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts) const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
return ( return (
<> <>
@@ -29,7 +31,9 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<span className="ads-eyebrow"> <span className="ads-eyebrow">
{tankvorgaenge.length === 0 {tankvorgaenge.length === 0
? "Keine Tankvorgänge" ? "Keine Tankvorgänge"
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`} : tankvorgaenge.length === 1
? "1 Tankvorgang"
: `${de(tankvorgaenge.length)} Tankvorgänge`}
</span> </span>
<button <button
type="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 <Accordion
key={jahr.jahr} key={jahr.jahr}
title={String(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]} defaultOpen={jahr === gruppen[0]}
> >
{jahr.monate.map((monat) => { {jahr.monate.map((monat) => {
@@ -64,7 +93,14 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
key={monat.schluessel} key={monat.schluessel}
level={2} level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))} 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]} defaultOpen={monat === jahr.monate[0]}
> >
{monat.eintraege.map((tank) => ( {monat.eintraege.map((tank) => (
@@ -102,21 +138,26 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
) )
})} })}
</Accordion> </Accordion>
))} )
})}
</> </>
) )
} }
function TankFormular({ beiFertig }: { beiFertig: () => void }) { function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten() const { api, fahrzeug } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState("") const [zeitpunkt, setzeZeitpunkt] = useState("")
const [liter, setzeLiter] = useState("") const [liter, setzeLiter] = useState("")
const [kosten, setzeKosten] = useState("") const [kosten, setzeKosten] = useState("")
const [kraftstoff, setzeKraftstoff] = useState<string>(KRAFTSTOFFSORTEN[0])
const [station, setzeStation] = useState("") const [station, setzeStation] = useState("")
const [km, setzeKm] = useState("") const [km, setzeKm] = useState("")
const [ersparnis, setzeErsparnis] = useState("")
const [fehler, setzeFehler] = useState<string | null>(null) const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false) const [laeuft, setzeLaeuft] = useState(false)
const smartdealAktiv = fahrzeug?.smartdeal.aktiv === true
const speichern = async () => { const speichern = async () => {
setzeFehler(null) setzeFehler(null)
const literZahl = Number(liter.replace(",", ".")) const literZahl = Number(liter.replace(",", "."))
@@ -132,12 +173,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
setzeLaeuft(true) setzeLaeuft(true)
try { try {
const kmZahl = Number(km.replace(",", ".")) const kmZahl = Number(km.replace(",", "."))
const ersparnisZahl = Number(ersparnis.replace(",", "."))
await api.tankvorgangAnlegen({ await api.tankvorgangAnlegen({
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(), ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
liter: literZahl, liter: literZahl,
kosten: kostenZahl, kosten: kostenZahl,
kraftstoff,
station: station || null, station: station || null,
km: Number.isFinite(kmZahl) && km ? kmZahl : null, km: Number.isFinite(kmZahl) && km ? kmZahl : null,
...(smartdealAktiv
? { ersparnis: Number.isFinite(ersparnisZahl) && ersparnis ? ersparnisZahl : null }
: {}),
}) })
beiFertig() beiFertig()
} catch (ursache) { } catch (ursache) {
@@ -158,6 +204,19 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeZeitpunkt(e.target.value)} onChange={(e) => setzeZeitpunkt(e.target.value)}
/> />
</Feld> </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"> <Feld label="Menge" unit="l">
<input <input
className="dm-eingabe" className="dm-eingabe"
@@ -176,6 +235,17 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) {
onChange={(e) => setzeKosten(e.target.value)} onChange={(e) => setzeKosten(e.target.value)}
/> />
</Feld> </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"> <Feld label="Tankstelle">
<input <input
className="dm-eingabe" className="dm-eingabe"
+16 -20
View File
@@ -1,8 +1,10 @@
/** /**
* Detailseite zu "Türen und Klappen geschlossen" - jede Tür einzeln, * Detailseite zu "Türen und Klappen geschlossen" - jede Tür einzeln,
* Motorhaube, Kofferraum. Einzige der vier Sammelzeilen aus Fahrzeugstatus.tsx * Motorhaube, Kofferraum. Einzige der vier Sammelzeilen aus Fahrzeugstatus.tsx
* mit eigener Detailseite. Vorlage: die frühere flache Liste in * mit eigener Detailseite. Vorlage: vTuerenKlappen() im Panel - deckungsgleiche
* Fahrzeugstatus.tsx (vor der Aufteilung in vier Sammelzeilen). * 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" import { Tile } from "@audi-dash/ui"
@@ -27,26 +29,20 @@ export function TuerenKlappen() {
return ( return (
<Tile> <Tile>
<span className="ads-eyebrow">Türen und Klappen</span> <span className="ads-eyebrow">Türen und Klappen</span>
<ul className="dm-pruefliste"> <div style={{ marginTop: 10 }}>
{punkte.map((punkt) => ( {punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile"> <div className="dm-wertzeile" key={punkt.label}>
<span <dt>{punkt.label}</dt>
className={ <dd style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
punkt.ok === true <span
? "dm-punkt dm-punkt--ok" className={`ads-dot ads-dot--${punkt.ok === null ? "warn" : punkt.ok ? "ok" : "bad"}`}
: punkt.ok === false aria-hidden="true"
? "dm-punkt dm-punkt--offen" />
: "dm-punkt dm-punkt--unbekannt" {punkt.ok === null ? "unbekannt" : punkt.ok ? "zu" : "offen"}
} </dd>
aria-hidden="true" </div>
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))} ))}
</ul> </div>
</Tile> </Tile>
) )
} }
+72 -51
View File
@@ -17,6 +17,7 @@ import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel } from "./bausteine" import { Leerzustand, NaviKachel } from "./bausteine"
import { Bild } from "./Bild" import { Bild } from "./Bild"
import type { ServicebuchEintrag } from "./Service" import type { ServicebuchEintrag } from "./Service"
import { StandortVorschau } from "./Standort"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole" import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
import { Typenschild, schildErkennen } from "./Typenschild" import { Typenschild, schildErkennen } from "./Typenschild"
@@ -64,12 +65,14 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
const sicherheitsstatus = const sicherheitsstatus =
fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn" 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 = const sicherheitstext =
fahrzeug.gesichert === true fahrzeug.gesichert === true
? "Sicher abgestellt" ? "Sicher abgestellt"
: fahrzeug.gesichert === false : fahrzeug.gesichert === false
? "Nicht vollständig verschlossen" ? "Nicht sicher abgestellt"
: "Zustand unbekannt" : "Status unbekannt"
const aktualisieren = async () => { const aktualisieren = async () => {
setzeAktualisiert(true) setzeAktualisiert(true)
@@ -93,14 +96,19 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} /> <Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht — {/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
das Modell selbst zeigt bereits das Schild. Ohne erkanntes 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
<div className="ads-title dm-szene__zusatz"> 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 ? ( {schild ? (
zusatz && <span className="ads-sport">{zusatz}</span> zusatz && <span>{zusatz}</span>
) : ( ) : (
<> <>
{einstellungen.fahrzeugtitel}{" "} {einstellungen.fahrzeugtitel}{" "}
{zusatz && <span className="ads-sport">{zusatz}</span>} {zusatz && <span>{zusatz}</span>}
</> </>
)} )}
</div> </div>
@@ -118,17 +126,27 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
onClick={() => geheZu("sicherheit")} onClick={() => geheZu("sicherheit")}
/> />
<Tile> {/* Reihenfolge deckungsgleich mit vHome() im Panel (".tankzeile", eigens
<span className="ads-eyebrow">Reichweite</span> als myAudi-App-Konvention kommentiert, die einzige Kachel im ganzen
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} /> Panel mit der Beschriftung UNTEN statt oben): Zahl und Prozent in
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} /> einer Zeile (unten ausgerichtet), dann der Balken, erst dann das
<div className="dm-tanktext"> Label "Reichweite". 60px-Hauptzahl (nicht 40 - das ist die Größe
<span>{de(fahrzeug.tankPct)} % im Tank</span> des kleineren "Nächster Service"-Werts), reine Prozentzahl ohne
<span> Zusatztext. Die frühere Liter-Schätzzeile ("rund X von Y l") wurde
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "} am 2026-08-16 aus dem Panel entfernt (Nutzerwunsch) und war hier nie
{de(einstellungen.tankvolumen)} l 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> </span>
</div> </div>
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<span className="ads-eyebrow" style={{ marginTop: "var(--sp-3)" }}>
Reichweite
</span>
</Tile> </Tile>
{/* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich {/* 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 Knopf zur Service-Seite, die Kilometerstand-Zeile bleibt reine
Anzeige. Deckungsgleich mit vHome() im Panel. */} Anzeige. Deckungsgleich mit vHome() im Panel. */}
<Tile> <Tile>
<button {/* Ohne Termin ist dieser Teil im Panel bewusst kein Knopf (kein
type="button" data-go, kein Chevron) - es gibt nichts, wohin er führen könnte.
className="dm-serviceblock" "differentiate controls from content" gilt auch für die
onClick={() => geheZu("service")} Abwesenheit von Inhalt. */}
> {service ? (
<span className="ads-eyebrow">Nächster Service:</span> <button type="button" className="dm-serviceblock" onClick={() => geheZu("service")}>
<span className="dm-serviceblock__chevron" aria-hidden="true"> <span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-serviceblock__chevron" aria-hidden="true">
</span>
{service ? ( </span>
<> {(() => {
{(() => { const Symbol = SERVICE_SYMBOL[service.art]
const Symbol = SERVICE_SYMBOL[service.art] const fig = (
const fig = ( <Fig
<Fig value={service.restKm !== null ? de(service.restKm) : datum(service.datum)}
value={service.restKm !== null ? de(service.restKm) : datum(service.datum)} {...(service.restKm !== null ? { unit: "km" } : {})}
{...(service.restKm !== null ? { unit: "km" } : {})} size={40}
size={40} />
/> )
) return Symbol ? (
return Symbol ? ( <div className="dm-servicefig">
<div className="dm-servicefig"> <Symbol groesse={31} />
<Symbol groesse={31} /> {fig}
{fig} </div>
</div> ) : (
) : ( fig
fig )
) })()}
})()} <span className="dm-fussnote">
<span className="dm-fussnote"> {service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art}
{service.restKm !== null ? `voraussichtlich am ${datum(service.datum)}` : service.art} </span>
</span> </button>
</> ) : (
) : ( <div className="dm-serviceblock">
<span className="ads-eyebrow">Nächster Service:</span>
<span className="dm-fussnote">kein Eintrag im Wartungsplan</span> <span className="dm-fussnote">kein Eintrag im Wartungsplan</span>
)} </div>
</button> )}
<div className="dm-servicezeile"> <div className="dm-servicezeile">
<span className="dm-servicezeile__k">Kilometerstand</span> <span className="dm-servicezeile__k">Kilometerstand</span>
<Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} /> <Fig value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"} unit="km" size={20} />
</div> </div>
</Tile> </Tile>
<StandortVorschau geheZu={() => geheZu("standort")} />
<div className="dm-raster"> <div className="dm-raster">
{letzteFahrt ? ( {letzteFahrt ? (
<NaviKachel <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() { export function Schutzbrief() {
const { fahrzeug } = useDaten() const { fahrzeug } = useDaten()
if (!fahrzeug) return null if (!fahrzeug) return null
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown> const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
const listen: [string, string[]][] = [ const ausloeser = (s["ausloeser"] ?? []) as string[]
["Auslöser", (s["ausloeser"] ?? []) as string[]], const entweder = (s["entweder"] ?? []) as SchutzbriefEntweder[]
["Entweder", (s["entweder"] ?? []) as string[]], const gruppen = (s["gruppen"] ?? []) as SchutzbriefGruppe[]
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]], // [Bezeichnung, Begründung] - dieselbe Zwei-Spalten-Form wie im Panel
["Nicht versichert", (s["nicht"] ?? []) as string[]], // (sb.nicht.map(([bez, begr]) => ...)), nicht ein Array aus Strings.
["Pflichten", (s["pflichten"] ?? []) as string[]], const nicht = (s["nicht"] ?? []) as [string, string][]
] const pflichten = (s["pflichten"] ?? []) as string[]
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
const hatInhalt =
ausloeser.length > 0 ||
entweder.length > 0 ||
gruppen.length > 0 ||
nicht.length > 0 ||
pflichten.length > 0
if (!hatInhalt) { if (!hatInhalt) {
return ( return (
@@ -319,30 +335,71 @@ export function Schutzbrief() {
return ( return (
<> <>
<Tile> {entweder.length > 0 && (
<span className="ads-eyebrow">Schutzbrief</span> <Tile>
<Werteliste <span className="ads-eyebrow">Die Entscheidung am Schadenort</span>
kinder={ <p className="dm-fussnote">
<> Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} /> die anderen beiden.
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} /> </p>
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} /> <Werteliste
</> kinder={entweder.map((x, i) => (
} <Wertzeile key={x.bez ?? i} label={x.bez ?? ""} wert={x.grenze ?? ""} zusatz={x.zusatz} />
/> ))}
</Tile> />
{listen <p className="dm-fussnote">Mobilität wird binnen 60 Minuten organisiert.</p>
.filter(([, eintraege]) => eintraege.length > 0) </Tile>
.map(([titel, eintraege]) => ( )}
<Tile key={titel}>
<span className="ads-eyebrow">{titel}</span> {ausloeser.length > 0 && (
<ul className="dm-liste"> <Tile>
{eintraege.map((eintrag) => ( <span className="ads-eyebrow">Wann der Schutzbrief greift</span>
<li key={eintrag}>{eintrag}</li> <ul className="dm-liste">
))} {ausloeser.map((a) => (
</ul> <li key={a}>{a}</li>
</Tile> ))}
))} </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" 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 * Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
* Hinweis, was als Nächstes passiert — nie nur „keine Daten". * Hinweis, was als Nächstes passiert — nie nur „keine Daten".
+15
View File
@@ -29,6 +29,7 @@ export const WEITERE_BILDER = [
"seitenansicht-winter.webp", "seitenansicht-winter.webp",
"rad-sommer.webp", "rad-sommer.webp",
"rad-winter.webp", "rad-winter.webp",
"draufsicht.webp",
] as const ] as const
export const ERLAUBTE_DATEINAMEN: readonly string[] = [ export const ERLAUBTE_DATEINAMEN: readonly string[] = [
@@ -47,3 +48,17 @@ export function bildUrl(dateiname: string | null | undefined): string | undefine
if (!basis) return undefined if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}` 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 { MeinAudi } from "./MeinAudi"
import { Reifen } from "./Reifen" import { Reifen } from "./Reifen"
import { Service, Servicebuch, Werkstatt } from "./Service" import { Service, Servicebuch, Werkstatt } from "./Service"
import { Standort } from "./Standort"
import { Statistik } from "./Statistik" import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail" import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken" import { Tanken } from "./Tanken"
@@ -65,6 +66,8 @@ export function SeiteFuer(props: SeitenProps) {
return <Fahrzeugstatus geheZu={geheZu} /> return <Fahrzeugstatus geheZu={geheZu} />
case "tuerenklappen": case "tuerenklappen":
return <TuerenKlappen /> return <TuerenKlappen />
case "standort":
return <Standort />
case "audi": case "audi":
return <MeinAudi geheZu={geheZu} /> return <MeinAudi geheZu={geheZu} />
case "ident": 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 () => { it("Türen und Klappen: zeigt nur die Punkte dieser Gruppe", async () => {
const { container } = zeige("tuerenklappen") 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.getByText("Motorhaube")).toBeTruthy()
expect(screen.queryByText("Standlicht")).toBeNull() expect(screen.queryByText("Standlicht")).toBeNull()
}) })
+28
View File
@@ -61,3 +61,31 @@
font-family: "Audi Type Extended", var(--font-stack); font-family: "Audi Type Extended", var(--font-stack);
font-style: italic; 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); color: var(--fg);
} }
.dm-kopf__aktion[aria-current="page"] { /* Kein rotes Aktiv-Signal hier: Rot bleibt Akzent/destruktiv, keine
color: var(--red); 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 */ /* ---------------------------------------------------------- Inhaltsfläche */
+523 -73
View File
@@ -234,11 +234,23 @@ input[type="date"].dm-eingabe {
/* ------------------------------------------------------------ Übersicht */ /* ------------------------------------------------------------ Ü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 { .dm-szene {
position: relative; position: relative;
border-radius: var(--r-tile); border-radius: var(--r-tile);
overflow: hidden; 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, .dm-szene .ads-bild,
@@ -247,8 +259,14 @@ input[type="date"].dm-eingabe {
display: block; 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 { .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, /* Übersicht: "Nächster Service" als tippbare Kennzahl innerhalb der Kachel,
@@ -267,9 +285,15 @@ input[type="date"].dm-eingabe {
font: inherit; font: inherit;
text-align: left; text-align: left;
color: inherit; 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; cursor: pointer;
} }
.dm-serviceblock:active { button.dm-serviceblock:active {
opacity: 0.55; opacity: 0.55;
} }
.dm-serviceblock__chevron { .dm-serviceblock__chevron {
@@ -282,10 +306,19 @@ input[type="date"].dm-eingabe {
} }
/* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." - /* Serviceart-Icon links der Kennzahl statt des Wortes "bis zum/zur ..." -
deckungsgleich mit .fig-zeile im Panel (audi-dashboard.css). */ 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 { .dm-servicefig {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 8px;
}
.dm-serviceblock > .ads-fig {
margin-top: 8px;
} }
.dm-servicefig svg { .dm-servicefig svg {
color: var(--fg2); color: var(--fg2);
@@ -339,6 +372,9 @@ input[type="date"].dm-eingabe {
margin-top: 8px; margin-top: 8px;
} }
.dm-tgwert { .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-weight: 300;
font-size: 22px; font-size: 22px;
color: var(--fg); color: var(--fg);
@@ -357,14 +393,19 @@ input[type="date"].dm-eingabe {
margin-top: 6px; margin-top: 6px;
} }
.dm-tanktext { /* Reichweiten-Kachel auf der Übersicht - deckungsgleich mit .tankzeile/.pct
im Panel (audi-dashboard.css). */
.dm-tankzeile {
display: flex; display: flex;
align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-3); gap: 14px;
margin-top: var(--sp-2); margin-bottom: 14px;
font-size: 12.5px; }
font-weight: 300; .dm-tankzeile__pct {
font-size: 15px;
color: var(--fg2); color: var(--fg2);
padding-bottom: 6px;
} }
.dm-navikachel__wert { .dm-navikachel__wert {
@@ -374,16 +415,27 @@ input[type="date"].dm-eingabe {
color: var(--fg); 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 { .dm-navikachel__zahl {
font-size: 20px; font-size: 15px;
font-weight: 300; font-weight: 400;
color: var(--fg2);
}
.dm-navikachel__wert .dm-navikachel__zahl + small {
font-size: 12.5px;
} }
.dm-navikachel__wert small, .dm-navikachel__wert small,
.dm-wertzeile small { .dm-wertzeile small {
display: block; display: block;
margin-top: 2px; margin-top: 2px;
font-size: 11.5px; font-size: 12.5px;
color: var(--fg3); color: var(--fg3);
} }
@@ -420,12 +472,17 @@ input[type="date"].dm-eingabe {
margin: var(--sp-3) 0 0; 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 { .dm-wertzeile {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-4); gap: var(--sp-4);
padding: 10px 0; padding: 11px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
@@ -434,15 +491,16 @@ input[type="date"].dm-eingabe {
} }
.dm-wertzeile dt { .dm-wertzeile dt {
font-size: 13px; font-size: 16px;
font-weight: 300; font-weight: 400;
color: var(--fg2); color: var(--fg);
} }
.dm-wertzeile dd { .dm-wertzeile dd {
margin: 0; margin: 0;
font-size: 14px; font-size: 16px;
font-weight: 300; font-weight: 400;
color: var(--fg2);
text-align: right; text-align: right;
} }
@@ -454,6 +512,60 @@ input[type="date"].dm-eingabe {
padding: var(--sp-3) 2px 0; 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 { .dm-fussnote {
margin: var(--sp-3) 0 0; margin: var(--sp-3) 0 0;
font-size: 11.5px; font-size: 11.5px;
@@ -465,60 +577,12 @@ input[type="date"].dm-eingabe {
/* ------------------------------------------------------ Fahrzeugstatus */ /* ------------------------------------------------------ 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 /* 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 so schon am Symbol erkennbar, nicht nur an der Farbe. Die Detailliste
oben, das bleibt für die Detailliste unverändert). */ "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 { .dm-statuskreis {
flex: none; flex: none;
width: 22px; 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__text { flex: 1; font-size: 14.5px; }
.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); } .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 */ /* ------------------------------------------------------------ Galerie */
.dm-galerie { .dm-galerie {
@@ -666,6 +745,14 @@ button.dm-listenzeile {
letter-spacing: -0.02em; 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 { .dm-detail__einheit {
font-size: 15px; font-size: 15px;
color: var(--fg2); color: var(--fg2);
@@ -795,6 +882,103 @@ button.dm-listenzeile {
margin-top: var(--sp-4); 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 { .dm-waehlen {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -836,14 +1020,18 @@ button.dm-listenzeile {
} }
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit /* 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 { .dm-szene__zusatz {
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
font-size: 19px; font-size: 13px;
line-height: 1.2; line-height: normal;
color: var(--fg2);
} }
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px), /* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
@@ -854,3 +1042,265 @@ button.dm-listenzeile {
justify-content: space-between; justify-content: space-between;
gap: var(--sp-3); 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) { export function SymbolAudi({ groesse }: SymbolProps) {
return ( return (
<Rahmen <Rahmen
groesse={groesse} 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) { export function SymbolWarnung({ groesse }: SymbolProps) {
return ( return (
<Rahmen <Rahmen
+14 -1
View File
@@ -37,7 +37,20 @@ export default defineConfig({
base: "./", base: "./",
plugins: [react()], plugins: [react()],
define: { __APP_VERSION__: JSON.stringify(appVersion) }, 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: { build: {
outDir: "dist", outDir: "dist",
target: "es2022", target: "es2022",
@@ -1 +1 @@
{"version":"2026.8.27.19","sha256":"9045270a7f0e7222ed00f583e0f131d0bdf20a79a69d6b6004ccb4f548206884","bytes":236046,"gebaut":"2026-08-27T21:58:57Z"} {"version":"2026.8.30.2","sha256":"88922cb0e3a7603b6e4e2fe7daee85513ec9e16c4ae7b2e0229f514bde58915f","bytes":248241,"gebaut":"2026-08-30T04:29:47Z"}
@@ -1054,7 +1054,6 @@ const CI = {
selectAll: '<path fill="currentColor" d="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"/>', selectAll: '<path fill="currentColor" d="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: '<path fill="currentColor" d="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"/>', planRoute: '<path fill="currentColor" d="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: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>', upload: '<path fill="currentColor" d="M13 20h-1V9.7l-3.65 3.65-.7-.7 4.85-4.86 4.85 4.86-.7.7L13 9.71zM6 5V4h13v1z"/>',
fuelRange: '<path fill="currentColor" d="M2 3.5A2.5 2.5 0 0 1 4.5 1h6A2.5 2.5 0 0 1 13 3.5V10h.5a2.5 2.5 0 0 1 2.5 2.5v5a1.5 1.5 0 0 0 3 0V8.07a16.4 16.4 0 0 0-2.89-5.76l.78-.62a17.4 17.4 0 0 1 3.1 6.17l.01.07v9.57a2.5 2.5 0 0 1-5 0v-5c0-.83-.67-1.5-1.5-1.5H13v7h-1V3.5c0-.83-.67-1.5-1.5-1.5h-6C3.67 2 3 2.67 3 3.5V22h4v1H1v-1h1zM4 10h7v1H4zm5 14v-5h1v2h13v-2h1v5h-1v-2H10v2z"/>',
parking: '<path fill="currentColor" d="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"/>', parking: '<path fill="currentColor" d="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"/>',
/* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, /* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */ verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
@@ -1142,7 +1141,7 @@ function vStandort() {
: `${ciSVG(CI.parking)}<span>${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}</span>`} : `${ciSVG(CI.parking)}<span>${zustand.seitTs != null ? "Geparkt seit " + dauerSeitText(zustand.seitTs) : "Fahrzeug steht"}</span>`}
</div> </div>
<div class="standortmenu-werte"> <div class="standortmenu-werte">
${ciSVG(CI.fuelRange)} <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS.fuel}</svg>
<span>${de(CAR.tankPct)} %</span> <span>${de(CAR.tankPct)} %</span>
<span>${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"}</span> <span>${CAR.reichweite != null ? de(CAR.reichweite) + " km" : "Reichweite unbekannt"}</span>
</div> </div>
@@ -1,7 +1,7 @@
{ {
"domain": "audi_dashboard", "domain": "audi_dashboard",
"name": "Audi Dashboard", "name": "Audi Dashboard",
"version": "2026.8.28.18", "version": "2026.8.30.2",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"], "codeowners": ["@paul"],
+46 -8
View File
@@ -11,22 +11,53 @@
border-bottom: 0; border-bottom: 0;
} }
/* Deckungsgleich mit .feld label in audi-dashboard-ios.css (16px,
var(--fg)) - war hier fälschlich als gedämpfte 13px/--fg2-Eyebrow gebaut,
derselbe Fehler wie bei .dm-wertzeile dt/dd (siehe die MeinAudi.tsx-
Rebuild-Notiz weiter oben, Parität-Audit 2026-08-30) - dort schon
korrigiert, hier nie nachgezogen, obwohl Feld weit öfter verwendet wird.
flex:1 1 auto + min-width:0 (aus dem Basisblatt audi-dashboard.css, von
der iOS-Auflage nicht neu gesetzt, bleibt also in Kraft): das Label nimmt
den übrigen Platz der Zeile und bricht bei Bedarf um, statt mit dem
Eingabefeld um Platz zu konkurrieren - ohne das brach ein langes Label
wie "Pause zwischen zwei Fahrten" Wort für Wort in eine schmale Spalte
um und quetschte das Eingabefeld zusammen (live beobachtet). */
.ads-feld label { .ads-feld label {
font-size: 13px; font-size: 16px;
color: var(--fg2); color: var(--fg);
flex: 1 1 auto;
min-width: 0;
line-height: 1.35;
} }
.ads-feld input, .ads-feld input,
.ads-feld select { .ads-feld select {
background: var(--tile-2); /* Deckungsgleich mit .feld input/.feld select in audi-dashboard-ios.css:
border: 1px solid var(--line); background:var(--ios-fill) (war --tile-2, ein anderer, weiß- statt
color: var(--fg); grau-basierter Farbwert), 16px statt 13.5px, Wert gedämpft (--fg2) bis
zum Fokus, erst dort --fg (Parität-Audit 2026-08-30). min-width:0 statt
einer festen 132px-Untergrenze (aus audi-dashboard.css übernommen,
"min-width:0;max-width:100%;flex:0 1 auto") - eine feste Untergrenze
verhinderte, dass das Feld dem Label (flex:1 1 auto oben) Platz
abtritt; ohne sie füllte der native Browser-Standardwert (~170-190px
ohne size-Attribut) dieselbe Lücke und quetschte lange Labels auf 0px
zusammen (live beobachtet, "Pause bis"). */
background: var(--ios-fill);
border: none;
color: var(--fg2);
font-family: inherit; font-family: inherit;
font-size: 13.5px; font-size: 16px;
padding: 8px 11px; padding: 8px 11px;
border-radius: var(--r-func); border-radius: var(--r-func);
text-align: right; text-align: right;
min-width: 132px; min-width: 0;
max-width: 100%;
flex: 0 1 auto;
}
.ads-feld input:focus,
.ads-feld select:focus {
color: var(--fg);
} }
.ads-mit-einheit { .ads-mit-einheit {
@@ -35,8 +66,15 @@
gap: var(--sp-2); gap: var(--sp-2);
} }
/* Deckungsgleich mit .mitEinheit input in audi-dashboard.css: feste 96px,
min-width auf 0 zurückgesetzt (schlägt sonst die ererbte 132px-Regel von
.ads-feld input, siehe oben) - ohne das griff für die Zahl kein
Breitenlimit, der native Browser-Standardwert (~170-190px) drängte das
Label bei längeren Feldnamen wie "Pause bis" bis auf 0px zusammen (live
beobachtet, Parität-Audit 2026-08-30). */
.ads-mit-einheit input { .ads-mit-einheit input {
min-width: 92px; width: 96px;
min-width: 0;
text-align: right; text-align: right;
} }
+6 -1
View File
@@ -1,6 +1,11 @@
.ads-fig { .ads-fig {
font-weight: 300; font-weight: 300;
letter-spacing: -.02em; /* -.03em, nicht -.02em: die iOS-Auflage im Panel überschreibt .fig dort
eigens (audi-dashboard-ios.css, live gemessen -1.8px bei 60px = -.03em)
- das Basisblatt allein (audi-dashboard.css, -.02em) reicht am echten
Panel nie aus, weil die Auflage immer mitgeladen wird (Parität-Audit
2026-08-30, derselbe Fehler wie bei den Farb-Tokens). */
letter-spacing: -.03em;
line-height: .95; line-height: .95;
color: var(--fg); color: var(--fg);
} }
+19 -8
View File
@@ -1,23 +1,34 @@
/* Deckungsgleich mit .seg/.seg button/.seg button.on in audi-dashboard-ios.css
(immer geladen, siehe die Parität-Audit-Funde weiter oben in diesem
Projekt): eine gefüllte Spur mit einer verschiebbaren Pille, keine
einzeln umrandeten Knöpfe, kein Rot - .ads-seg__on war hier bislang der
pre-iOS-Entscheidung-Stil (Großschreibung, rote Umrandung), derselbe
Fehler wie bei .ads-eyebrow/.ads-quad__l/TabBar. */
.ads-seg { .ads-seg {
display: flex; display: flex;
gap: var(--sp-2); gap: 0;
padding: 2px;
background: var(--ios-fill);
border-radius: 9px;
} }
.ads-seg button { .ads-seg button {
flex: 1; flex: 1;
border: 1px solid var(--line-strong); border: 0;
background: none; background: none;
color: var(--fg2); color: var(--fg2);
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 13px;
letter-spacing: .12em; font-weight: 400;
text-transform: uppercase; letter-spacing: normal;
padding: 11px 8px; text-transform: none;
border-radius: var(--r-pill); padding: 7px 8px;
border-radius: 7px;
cursor: pointer; cursor: pointer;
} }
.ads-seg button.ads-seg__on { .ads-seg button.ads-seg__on {
border-color: var(--red); background: var(--tile-2);
color: var(--fg); color: var(--fg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
} }
@@ -38,8 +38,8 @@
} }
.ads-switch input:checked + .ads-switch__track { .ads-switch input:checked + .ads-switch__track {
background: var(--red); background: var(--ok);
border-color: var(--red); border-color: var(--ok);
} }
.ads-switch input:checked + .ads-switch__track::before { .ads-switch input:checked + .ads-switch__track::before {
+12 -3
View File
@@ -11,17 +11,26 @@
padding: 10px 0 8px; padding: 10px 0 8px;
background: none; background: none;
border: none; border: none;
border-top: 2px solid transparent;
margin-top: -1px;
color: var(--fg3); color: var(--fg3);
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
} }
/* Aktiver Tab: eine graue Fläche hinter Icon+Beschriftung, kein Rot - Rot
bleibt Akzent/destruktiv, nie Navigationsfarbe (dieselbe Regel wie beim
Panel-Zurückpfeil, den primären Aktionsknöpfen und der Arbeitsweg-Pille).
Werte 1:1 vom Panel übernommen (.tabbar:not(.ohne) .tab.on in
audi-dashboard-ios.css) - kein bestehendes Token deckt genau diese Deckkraft
ab (--shade ist .05/.04, hier .14/.07), deshalb hier direkt benannt, wie im
Original. */
.ads-tab--on { .ads-tab--on {
border-top-color: var(--red); background: rgba(255, 255, 255, 0.14);
border-radius: 14px;
color: var(--fg); color: var(--fg);
} }
[data-theme="tag"] .ads-tab--on {
background: rgba(0, 0, 0, 0.07);
}
.ads-tab__icon svg { .ads-tab__icon svg {
width: 21px; width: 21px;
+40 -28
View File
@@ -9,7 +9,7 @@
:root { :root {
/* Markenunabhängig */ /* Markenunabhängig */
--red: #F50537; --red: #F50537;
--r-tile: 20px; --r-tile: 16px;
--r-pill: 999px; --r-pill: 999px;
--r-func: 8px; --r-func: 8px;
--rahmen-hg: #111; --rahmen-hg: #111;
@@ -21,39 +21,46 @@
--sp-4: 16px; --sp-4: 16px;
--sp-5: 22px; --sp-5: 22px;
/* Nacht (Default-Theme, entspricht dem :root-Block des Originals) */ /* Nacht (Default-Theme). Bis 2026-08-30 standen hier die Werte aus dem
--canvas: #161b23; *Basis*-Blatt audi-dashboard.css - das Panel lädt aber IMMER zusätzlich
--tile: #1f2733; die iOS-Auflage (audi-dashboard-ios.css), die praktisch die gesamte
--tile-2: #2a3341; Palette überschreibt (":host{--r-tile:16px...}" +
--line: rgba(255, 255, 255, .10); ":host([data-theme=nacht/tag])"). Live am echten Panel gemessen statt
--line-strong: rgba(255, 255, 255, .20); wieder nur aus der Basisdatei gelesen (Parität-Audit) - das ist die
tatsächlich wirksame Palette, --r-tile oben eingeschlossen. */
--canvas: #0C1014;
--tile: rgba(255, 255, 255, .055);
--tile-2: rgba(255, 255, 255, .10);
--line: rgba(255, 255, 255, .13);
--line-strong: rgba(255, 255, 255, .24);
--fg: #FFFFFF; --fg: #FFFFFF;
--fg2: #9aa1ad; --fg2: #BFC4CC;
/* Design-Audit A2: von #657081 (nur 3,0:1 auf --tile, WCAG AA verfehlt) auf --fg3: #8E8E93;
4,9:1 angehoben. Das Panel hat diesen Wert bereits, hier war er noch der --ok: #30D158;
alte nachgezogen, damit beide dasselbe zeigen. */ --warn: #FFD60A;
--fg3: #8a94a3; --bad: #FF453A;
--ok: #15da15;
--warn: #ffaa00;
--bad: #fd2c4e;
--shade: rgba(255, 255, 255, .05); --shade: rgba(255, 255, 255, .05);
/* Graue Füllfläche für Bedienelemente (Seg-Spur, Eingabe-/Auswahl-Boxen) -
live am Panel gemessen (--ios-fill, audi-dashboard-ios.css). */
--ios-fill: rgba(120, 120, 128, .24);
--font-stack: Helvetica, Arial, sans-serif; --font-stack: Helvetica, Arial, sans-serif;
} }
[data-theme="tag"] { [data-theme="tag"] {
--canvas: #FFFFFF; --canvas: #F2F2F7;
--tile: #f2f2f2; --tile: #FFFFFF;
--tile-2: #e5e5e5; --tile-2: #EFEFF4;
--line: rgba(0, 0, 0, .10); --line: rgba(60, 60, 67, .13);
--line-strong: rgba(0, 0, 0, .22); --line-strong: rgba(60, 60, 67, .24);
--fg: #000000; --fg: #000000;
--fg2: #4c4c4c; --fg2: #3C3C43;
--fg3: #666666; --fg3: #8E8E93;
--ok: #0DA20D; --ok: #34C759;
--warn: #ffaa00; --warn: #FF9F0A;
--bad: #eb0d3f; --bad: #FF3B30;
--shade: rgba(0, 0, 0, .04); --shade: rgba(0, 0, 0, .04);
--ios-fill: rgba(120, 120, 128, .16);
} }
/* /*
@@ -78,9 +85,14 @@
} }
.ads-title { .ads-title {
font-size: 23px; /* Live am Panel gemessen (.title, audi-dashboard-ios.css): 22px,
letter-spacing: -.015em; letter-spacing -.01em (war hier -.015em), line-height 1.2 (war 1.15) -
line-height: 1.15; beide kleinen Drifts nie dokumentiert, jetzt exakt angeglichen
(Parität-Audit 2026-08-30, zusammen mit der 23px22px-Korrektur vom
2026-08-29). */
font-size: 22px;
letter-spacing: -.01em;
line-height: 1.2;
color: var(--fg); color: var(--fg);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;