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:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "companion-app",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "companion-app"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
1–10 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed
|
1–10 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed
|
||||||
@@ -4830,6 +4833,799 @@ button never renders. The manifest is unaffected — the iOS installer parses it
|
|||||||
of content type — so the working route is to paste the `itms-services://` URL into Safari's address
|
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 1–4 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 ~120–130px 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 10–15V 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.6–12.2V`
|
||||||
|
= bad→warn blend, `12.2–12.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.2–12.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
|
||||||
|
(~170–190px) 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 1–11, 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 2–11).
|
||||||
|
|
||||||
|
**`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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } : {})}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
Binary file not shown.
@@ -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"],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 23px→22px-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;
|
||||||
|
|||||||
Reference in New Issue
Block a user