From 4a58021670e49b17cabde0c4f18d7fe7143a918f Mon Sep 17 00:00:00 2001 From: Tobi G Date: Sun, 30 Aug 2026 18:31:41 +0200 Subject: [PATCH] Paritaetsrunden 2 und 3, Designpruefung umgesetzt (2026.8.30.8) Drei zusammenhaengende Runden, alle live bei 375x812 gegen audi_ha_test geprueft und in beiden Codebasen angewandt. Paritaetsrunde 2: der gemeinsame Rahmen war das eigentliche Problem - Seitenrand, Kachelabstaende, Kopfleiste, Tableiste und vier Bausteine des Design-Systems trugen noch den Stand vor der iOS-Entscheidung. Dazu sieben Bildschirme neu aufgebaut. Zwei Panel-Fehler dabei mitbehoben: teaser() zeigte unter "Letzte Fahrt" den aeltesten Datensatz, und "Daten bearbeiten" war ein toter Knopf. Paritaetsrunde 3: das Panel hat nie Audi Type gerendert. @font-face in einem Shadow Root wird ignoriert - Schriftschnitte registriert der Browser pro Dokument, nie pro Shadow Tree. Die drei Schnitte liegen jetzt in audi-dashboard-schriften.css und werden ins Dokument gehaengt. Damit erledigt sich eine ganze Reihe von "die Schrift sieht anders aus"-Eindruecken: die beiden Anwendungen zeigten tatsaechlich verschiedene Schriften. Ausserdem "Daten bearbeiten" im Panel gebaut und portiert, das Zeilenmenue entfernt und die letzten neun Bildschirme angeglichen. Designpruefung: die fuenf Punkte der Reihenfolge. Beim vierten hat das Messen den Befund veraendert - gezaehlt waren die deklarierten Groessen, wirksam war laengst eine saubere Sieben-Schritt-Skala mit 27 Ausreissern; die sind jetzt auf den naechsten Schritt gezogen, keiner verschiebt sich um mehr als 1px. Zuletzt: die Standortvorschau zeichnete die falsche Nadel (das Panel wechselt den Icon-Satz ab 34px, die App nahm immer den grossen), und der Kopfabstand der App ist auf den sicheren Bereich reduziert - die 56px des Panels liegen dort unter der Kopfleiste von Home Assistant, in der nativen Huelle steht darueber nichts. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 197 +++++- companion-app/src/App.tsx | 3 + companion-app/src/Shell.tsx | 146 +++- companion-app/src/api/index.ts | 28 + companion-app/src/daten/geokodierung.ts | 70 ++ companion-app/src/daten/statistik.test.ts | 3 +- companion-app/src/daten/statistik.ts | 23 +- companion-app/src/format.test.ts | 6 +- companion-app/src/format.ts | 49 +- companion-app/src/navigation.ts | 26 +- companion-app/src/screens/Batterie.tsx | 315 +++++++-- companion-app/src/screens/BatterieListe.tsx | 6 +- companion-app/src/screens/Bild.tsx | 14 +- companion-app/src/screens/Einstellungen.tsx | 392 +++++------ companion-app/src/screens/FahrtDetail.tsx | 123 ++-- companion-app/src/screens/Fahrten.tsx | 162 +++-- companion-app/src/screens/Fahrzeugdaten.tsx | 297 ++++++-- companion-app/src/screens/LiveFahrt.tsx | 2 +- companion-app/src/screens/MeinAudi.tsx | 97 ++- companion-app/src/screens/Reifen.tsx | 8 +- companion-app/src/screens/Service.tsx | 424 +++++++----- companion-app/src/screens/Standort.tsx | 16 +- companion-app/src/screens/Statistik.tsx | 228 ++++--- companion-app/src/screens/TankDetail.tsx | 175 ++--- companion-app/src/screens/Tanken.tsx | 261 ++++--- .../src/screens/TankstellenKarte.tsx | 46 ++ companion-app/src/screens/Uebersicht.tsx | 222 +++--- companion-app/src/screens/Versicherung.tsx | 480 ++++++++----- companion-app/src/screens/Zeilenmenue.tsx | 46 +- companion-app/src/screens/bausteine.tsx | 151 ++++- companion-app/src/screens/bilder.ts | 34 +- companion-app/src/screens/register.tsx | 6 +- companion-app/src/screens/screens.test.tsx | 12 +- companion-app/src/stile/audi-schrift.css | 3 + companion-app/src/stile/grundlage.css | 23 +- companion-app/src/stile/rahmen.css | 141 +++- companion-app/src/stile/screens.css | 634 ++++++++++++++++-- companion-app/src/symbole.tsx | 65 +- companion-app/src/theme.ts | 34 +- companion-app/vite.config.ts | 7 + .../audi_dashboard/frontend/app/bundle.json | 2 +- .../audi_dashboard/frontend/app/bundle.zip | Bin 248241 -> 256098 bytes .../frontend/audi-dashboard-app.js | 185 ++++- .../frontend/audi-dashboard-ios.css | 12 +- .../frontend/audi-dashboard-schriften.css | 17 + .../frontend/audi-dashboard.css | 68 +- .../audi_dashboard/manifest.json | 2 +- .../src/components/Accordion/Accordion.css | 41 +- .../src/components/Accordion/Accordion.tsx | 4 +- .../src/components/Accordion/LeafRow.css | 2 +- .../components/ActionButton/ActionButton.css | 53 +- .../components/ActionButton/ActionButton.tsx | 4 +- design-system/src/components/Fig/Fig.css | 5 +- .../ImagePlaceholder/ImagePlaceholder.css | 22 +- .../ImagePlaceholder/ImagePlaceholder.tsx | 2 +- design-system/src/components/Pill/Pill.css | 14 +- design-system/src/components/Popup/Popup.css | 2 +- .../components/ProgressBar/ProgressBar.css | 8 +- .../src/components/StatusRow/StatusRow.css | 26 +- .../src/components/StatusRow/StatusRow.tsx | 16 +- .../src/components/SwipeRow/SwipeRow.css | 24 +- .../src/components/TabBar/TabBar.css | 61 +- design-system/src/components/Tile/Tile.css | 5 + design-system/src/tokens/tokens.css | 12 +- 64 files changed, 4130 insertions(+), 1432 deletions(-) create mode 100644 companion-app/src/daten/geokodierung.ts create mode 100644 companion-app/src/screens/TankstellenKarte.tsx create mode 100644 custom_components/audi_dashboard/frontend/audi-dashboard-schriften.css diff --git a/AGENTS.md b/AGENTS.md index b7f3e22..c474ece 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,12 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-08-30** (a full 11-chapter panel↔companion-app visual/functional parity audit, +**Last updated: 2026-08-30** (design-audit follow-through - the five priority items applied to +both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN. +Before that: third parity round — the panel had never actually rendered Audi +Type (@font-face inside a shadow root is ignored), "Daten bearbeiten" now works in both, and the +last nine screens are aligned; manifest `2026.8.30.5`, see section AM. Before that: second parity round — the shared chrome turned out to be the real +problem, plus seven screens rebuilt and two panel-side defects fixed; manifest `2026.8.30.3`, see +section AL. Before that: 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 @@ -5624,7 +5630,194 @@ Rebuilt to match each field's real shape and the panel's own grouping: `entweder 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. +**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. **Done 2026-08-30 in section AL below.** + +## AL. Second parity round: the *global* chrome was the real problem, plus seven screens rebuilt (2026.8.30.3) + +Owner, after the first audit shipped: **"Die HA App in der Smartphone Ansicht ist die Vorgabe. Die iOS App soll identisch zu dieser Ansicht sein... Alle Abweichungen der iOS App zur HA App sind in der iOS App anzuwenden. Fehlende Features sind einzubauen. Copy exactly."** Two explicit decisions taken during the round (`AskUserQuestion`): **(1)** where the panel is demonstrably wrong, fix the *panel* rather than copy the defect; **(2)** where companion-app has *more* than the panel, remove it. + +**Method note that changed the findings, worth keeping:** `innerText` on a freshly-rendered panel view is read *too early* — placeholder markup for a missing photo only appears once the `` `onerror` fires. Two screens (Sicherheit, Reifen) looked like they had extra content in companion-app until the panel was re-dumped with a ~900 ms delay, at which point both matched exactly. **Always wait after navigating the panel before comparing text.** + +### The biggest find: the app-wide chrome, not any single screen + +Every screen was wrong at once because the shared frame was. All measured live, panel vs. companion-app, at 375×812: + +| | Panel | companion-app (before) | +|---|---|---| +| Content side padding | 16px (`main#view{--seitenrand:16px}`) | 22px (`--sp-5`) — every tile 20px narrower | +| Tile padding | `16px 18px` | `22px` | +| Tile-to-tile gap | 14px | **24px** (flex `gap` *plus* the tile's own margin) | +| Scrollbar | hidden | 8px wide, stealing content width on every screen | +| `--line` (day mode) | `rgba(60,60,67,.16)` | `.13` — every separator too faint | +| Missing-value dash | `–` (en dash, 30×) | `—` (em dash, 56×) | + +Header: the panel's `.eyebrow` is `display:none` on phone width — companion-app printed a fixed **"DataMetric360"** above every single screen title, an element the panel does not have at all. The panel also **hides the rings on sub-pages** (back arrow instead) and renders sub-page titles at **17px centered** (iOS navigation-bar style) against 22px left-aligned here; it has no border under the header; and **nine route titles** were plain drift (`Fahrzeugdaten`→`Identität und Technik`, `Batterie`→`Batteriespannung`, `Versicherung & Steuer`→`Versicherung/Steuer`, `Beitrag`→`Beitrag anpassen`, `Notruf`→`Rufnummern`, `Werkstatt`→`Autohaus`, `Wartungsplan`→`Eintrag`, `Fahrt`→`Einzelfahrt`, `Tankvorgang`→`Einzelbeleg`). + +Tab bar: labels were **9px UPPERCASE with .08em tracking** (the pre-2026-08-13 style again), icons 21px instead of 26px, no 63×39 icon pill, no translucent blur, no 26px home-indicator padding. + +`@audi-dash/ui` carried three more app-wide instances of the same pre-iOS styling, each fixed against live-measured panel values: **`ActionButton`** (11px uppercase outline with a red hover → 16px normal text on a filled 14px-radius surface, plus a new `primary` variant for `.aktion.primaer`), **`Pill`** (10px uppercase outline → 12px normal on `--ios-fill`), and **`Accordion`** (chevron on the *wrong side* — right instead of left —, 6×10 instead of square 8×8 so it read as a tick rather than an arrow, and it turned **red** when expanded, the same "red as a navigation state" violation fixed twice before elsewhere). Also `Fig`'s unit lost its `max(11px, …)` floor, so the 20px odometer figure rendered its "km" at **6.4px**; `ProgressBar` was 4px on `--line` instead of 6px on `--ios-fill`; `StatusRow` had no padding/min-height and a 14px title instead of 16px; `ImagePlaceholder` was a dashed outline with a 10.5px tracked caption instead of the panel's filled `--ios-fill` box with a 13px caption; and `Tile`'s content span lacked `min-width:0`, so the Fahrgestellnummer pushed its row out of the tile. + +### Features that were missing entirely + +- **Pull-to-refresh** (`.ptr` + the gesture) and the **"vor 3 Std." data-age line** in the header — companion-app had neither, and instead a "Jetzt beim Fahrzeug nachfragen" button the panel does not have. Built in `Shell.tsx` (`useZiehenAktualisieren`, `standAlter()` in `format.ts`), the button removed. +- **Full-bleed vehicle image** with the fade-out gradient, the **ground shadow** (`strasse`/`winter`) and its **"Boden" switch** (`useBoden` in `theme.ts`), and the **winter side-view swap** (`bildInfo()` in `bilder.ts`). The image labels also differed ("Auto von der Seite" vs. the panel's "Auto Seite"). +- **`Termin vereinbaren`**, the full **Autohaus** tile (address, phone, e-mail, maps links) and the **Wartungsplan list** with "Eintrag hinzufügen" on the Service screen — companion-app had two `NaviKachel`s instead, and the `sbuch` route was a whole list screen where the panel's is a **single-entry editor**. Route and screen rebuilt accordingly. +- The **Statistik tiles are collapsible in the panel** (`statTile()` → `.tile-h`/`.tile-b`) with a right-aligned secondary value in the head; companion-app rendered everything permanently expanded, without the head value and without the `quad()` sub-headings. Rebuilt, and `schnittFahrtVerbrauch()` (the missing "Näherung je Fahrt, je Zeitraum" row) added to `statistik.ts`. +- **Batterie**: the panel's chart has grid lines, Y-axis labels, an area fill, a date-range line, and pinch-zoom/pan with a reset button — none of it existed here; and the whole **"Bewertung" tile** (SOC with colour dot and label, SOH trend with its verdict text) was missing, replaced by four plain value rows. +- **`Vertragsdetails` rendered twelve em-dashes.** `versicherung.leistungen` is `{gruppe, posten[]}[]`, not `{bez, wert}[]` — the same mis-typed-data-shape defect as the `Schutzbrief()` `[object Object]` bug from chapter 4, one level up. Also on the Versicherung group: the whole `vVers()` structure (Notruf tile with *both* numbers as dial buttons, Jahresbeitrag with its 46px figure, per-part breakdown and segmented bar, Vertrag with Selbstbeteiligung rows and the SF "zuvor" line) was rebuilt; `Beitrag` gained "Gültig ab" and both SF fields; `Vertrag` gained the "Schadenfreiheitsklasse · zuvor" tile; `Notruf` was relabelled Inland/Ausland; `Steuer` got the Zeitraum dropdown, "Nächste Fälligkeit" and the Lastschrift switch. +- **`Fahrzeugdaten` hid over 3 000 characters of data sheet inside collapsed accordions** — the panel shows every technical group and every equipment group as its own flat tile. Rebuilt; the head tile also had the wrong name ("Identität" vs "Fahrzeug") and the wrong field set. +- **Fahrten/Tanken list rows** used a different anatomy (km/litres first, everything else in one long footer line, plus Arbeitsweg/offen/unvollständig pills the panel has no equivalent for). Rebuilt on a new shared `Blattzeile` (`.leaf` in the panel), the count header and top "eintragen" button removed (the panel puts the count in the year row and the form *below* the list), month labels shortened to the month name, and **`nachJahrUndMonat()` was sorting months and rows descending** where the panel keeps the backend's ascending order — both lists were reversed and opened the wrong month by default. + +### Panel-side changes (both decided with the owner, not taken unilaterally) + +1. **`teaser()` showed the OLDEST trip and fill, not the newest** — `TRIPS[0]`/`FILLS[0]` on backend lists that arrive ascending, under headings that read "Letzte Fahrt"/"Letzter Tankvorgang". Found only because companion-app sorted correctly and the two disagreed. Fixed in the panel (sort by timestamp, take the newest); companion-app already behaved correctly and was left alone. +2. **`vIdent()`'s "Daten bearbeiten" button is dead** — no `data-go`, no handler, does nothing when tapped. Deliberately **not** copied into companion-app; reported instead, since a dead control is not a feature worth reproducing. + +### Deliberately not removed, despite decision (2) — needs an owner call + +`Zeilenmenue`'s always-visible "···" button on trip/fill rows has no panel counterpart (the panel is swipe-only), but it exists to close a recorded accessibility finding (`AUDIT_2026-08-10.md` §4.1: swipe-only deletion is unreachable with VoiceOver or switch control). Removing it would re-open that defect, so it stays until the owner decides between "remove for exact parity" and "port it into the panel instead". + +### Still open after this round + +Einstellungen (section names, order and content differ substantially in both directions), MeinAudi, Standort, Schutzbrief, FahrtDetail, TankDetail, Werkstatt, BatterieListe, and the receipt-upload buttons the panel offers inside the *new*-fill form (`Beleg hochladen` / `Ohne Beleg speichern`) — companion-app only offers receipt upload on an existing fill's detail page. + +Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `design-system` rebuilt after each of its CSS changes, `node --check` on the panel bundle, and **live side-by-side at a real 375×812 viewport for every screen touched** — Übersicht, Statistik, Service, Versicherung and Vertragsdetails now produce **byte-identical `innerText`** to the panel, and Übersicht additionally matches element for element (tile heights 130/89/106/169/190, gaps 0/20/22/22/14). Manifest bumped to `2026.8.30.3`, `npm run ota` rebuilt (`bundle.zip` 253,799 bytes, sha256 `8d22031998d6cd18b4d2b3072f16ea87bd3600a107cc6a3c831ba20d3fd8c7ef`), integration redeployed to `audi_ha_test` (`docker cp …/. `, trailing dot per section AK) and confirmed via a clean single-warning setup log. + +**Dev-proxy note:** `vite.config.ts`'s dev proxy now also forwards `/local` and `/audi_dashboard_static` to `:18123`. Without it the dev server answered image requests itself, so every optical comparison saw placeholders instead of the real photos and the Shell logo. `server.*` still never applies to `vite build`. + +--- + + +## AM. Third parity round: the panel had never rendered Audi Type, plus the last nine screens (2026.8.30.5) + +Owner's three instructions after round two: **(1)** accessibility is not needed in either app — remove the "···" row menu; **(2)** "Daten bearbeiten" is required, so make it work *in the panel* first and then port it; **(3)** carry on with the open list. + +### The find of the round: the panel was showing Helvetica, not Audi Type + +Chasing a single wrapped list row ("Fahrt ohne Ortsangabe" broke onto two lines in the app, one in the panel) turned into the largest defect found so far. Same declared CSS on both sides, same 16px/400, and — verified by hash — **byte-identical woff2 files**. Yet the same string measured **173px in the app and 165.4px in the panel**. + +165.4px is exactly Helvetica. Probing inside the panel's shadow root settled it: `"Audi Type"`, `"Times New Roman"`, `serif` and a deliberately invented family name all measured **145.7px** — the panel resolved every one of them to the same fallback, i.e. **"Audi Type" was not available to the panel at all**, and its text had always come out in Helvetica (the second entry of its own font stack). `document.fonts` listed no Audi face either. + +Cause: the panel appends *both* stylesheets to its shadow root, and the @font-face rules sat at the top of `audi-dashboard.css`. **@font-face inside a shadow root is ignored — font faces are registered per document, never per shadow tree.** The mirror image of this project's own Leaflet bug (that stylesheet was in `document.head` and never reached the shadow root); same root cause, opposite direction. Fixed by splitting the three faces into `audi-dashboard-schriften.css` and linking *that one file* into `document.head` (`schriftenLaden()`); it contains nothing but @font-face, so the rest of the Home Assistant interface is untouched. Verified live afterwards: the panel now reports both Audi faces as loaded and measures the reference string at 173px — identical to the app. + +This also retires a long tail of "the fonts look slightly different" impressions: until now the two apps genuinely rendered different typefaces. + +### "Daten bearbeiten" (panel first, then ported) + +The button in `vIdent()` had no `data-go` and no handler — dead. Now implemented in the panel (`vIdentBearbeiten()`, `identBearbeitenOffen`, reset in `go()`/`zurueck()`): every value of the technical data sheet becomes an input, every equipment entry an editable row, both with "×" and "+ Position hinzufügen", saved through `profilSpeichern()` per field — the same idiom as the Selbstbeteiligung editor. Deliberately **not** included: Modell, Kennzeichen, Ausführung, Erstzulassung and FIN, which "Fahrzeug einrichten" in Einstellungen already owns; two editors for one field drift apart. Verified live in the container (add → 8 rows → delete → 7 rows, persisted). Then ported to `Fahrzeugdaten.tsx` with the app's own draft-plus-"Fertig" idiom (≈50 fields would otherwise mean ≈50 whole-profile writes). + +### Row menu removed + +`Zeilenmenue` is now just the swipe row, as in the panel. `SymbolMehr` and the `.dm-zeile*` classes went with it (orphans of this change). `SwipeRow`'s delete button also still carried the pre-iOS styling (11px uppercase, tracked) and lacked the panel's `visibility:hidden`-until-swiped trick — both fixed. Rows now use the full width, exactly like the panel. + +### The remaining nine screens + +- **Einstellungen** rebuilt to `vEinst()`'s sections, order and wording: "Fahrzeug einrichten" is collapsible again and carries the identity fields plus the history import; "Darstellung" gained the missing **"Fahrbahn unter dem Fahrzeug"** switch (the setting existed since round two, the control did not); "Bild der Übersicht" shows the chosen view large with **tap-to-upload/replace/delete** — the app previously could not change photos at all and pointed at Home Assistant, although `bild_hochladen`/`bild_loeschen` have existed all along (new `api.bildHochladen`/`api.bildLoeschen` and a shared `BildMitMenue`). "Tankrabatt"→"Kraftstoff-Rabatt", "Sicherung"→"Backup" with the panel's texts and button labels, "Vergangene Daten" folded into "Fahrzeug einrichten", Fahrzeugprofil moved above Backup. Removed: "Nacht beginnt/endet", the two count rows under "Zugang", and several explanation paragraphs the panel does not have. +- **Mein Audi**: the Service tile shows **all three terms** again (the deliberate Chapter-2 simplification is gone), image captions use the panel's labels ("Auto Seite"), and the Batteriespannung row lost its chevron (the panel marks it only by cursor). +- **FahrtDetail** and **TankDetail** rebuilt to `vTrip()`/`vFill()`: map first, then the flat 52px figure tile, then one value-row tile in the panel's order with "Werte bearbeiten"/"Beleg ersetzen" inside it and the destructive action last. Both had their own header row, their own ordering and labels, and extra pills the panel does not carry. +- **New-fill form** gained the panel's three buttons (**"Beleg hochladen"**, "Speichern", "Ohne Beleg speichern"). A receipt upload *without* `tank_id` only parses and publishes the fields (`belege.py`), which the form now waits for and pre-fills — the app previously could only attach a receipt to an already-existing fill. +- **Einzelbeleg map**: the record has no station coordinates and the backend never fills them; the panel geocodes the receipt address through Nominatim with a localStorage cache. Ported as `daten/geokodierung.ts` plus `TankstellenKarte`. +- **Schutzbrief**: the per-row note belongs under the *label*, not under the value (`dt` = bez + small zusatz, `dd` = grenze) — it was the other way round; explanation paragraphs aligned. +- **Werkstatt** tile renamed to "Angaben zum Autohaus"; **Messwertliste** timestamps use "·" instead of a comma (new `datumUndZeit()`); **Standort** already matched exactly. +- New `.dm-erklaerung` class for the panel's 12.5px explanation paragraphs. This finally resolves the `.dm-fussnote` ambiguity flagged twice: rather than change a class used 35 times across 13 files, the panel-shaped role got its own class. + +### Deliberately kept, with reasons + +- **"Zugang" → "Verbindung trennen"** and the **App-Update (OTA)** tile have no panel counterpart *because they cannot*: the panel runs inside Home Assistant and knows neither a server address nor a bundle. Same category as the onboarding screen and `LiveFahrt`. +- **`Tankvolumen` and `Zusatz`** are in the data model but editable nowhere in the panel, so their editors were removed here per the owner's rule. They are now only changeable in the profile file — say the word and they get an editor in the panel instead. + +Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `node --check` on the panel bundle, live comparison at 375×812 for every screen touched. Manifest `2026.8.30.5`, `npm run ota` rebuilt (`bundle.zip` 255,695 bytes, sha256 `43fdc62a88cc212d98a808239c650e1d6a3cb33c243a576ef36014c0b8948943`), integration redeployed to `audi_ha_test`, clean restart with no traceback, and the font fix confirmed live in the panel. + +**Tooling note worth keeping:** do not use `perl -pi -e 's|...|...|'` on these files. A pattern containing an escaped `|` terminated early, the substitution then matched every line, and `MeinAudi.tsx` came back with the replacement prepended to all 250 lines. It was fully reversible (the injection is deterministic), but the safe path is a small Node script that reads the file, checks the anchor exists, replaces once, and preserves CRLF — the source files are CRLF and a naive newline pattern will silently not match. + +--- + + + +## AN. Design audit follow-through: the five priority items, and what measuring first changed (2026.8.30.6/.7) + +Working through the priority list of the design audit published at +https://claude.ai/code/artifact/cb5bcae6-5ca5-43c1-a575-cdff96eb2a54. Every item was applied to +**both** codebases (parity rule) and verified live at 375x812 against `audi_ha_test`. + +**1. Statistik tiles had no expand affordance** - the five collapsible tiles looked like fixed +readouts. The rotating chevron the accordion rows already use now sits in the tile head +(`.stat-kopfzeile` + `.mark` in the panel, `dm-statkopf__kopfzeile` + `ads-mark` in the app). + +**2. Lists ran newest-last inside a newest-first frame.** Years descended while months and rows +ascended, so the newest trip sat at the bottom, off screen. Panel got `neuesteZuerst()` for +`vTrips()`/`vFuel()`; the app's `nachJahrUndMonat()` went fully descending. `teaser()` in the panel +was picking `TRIPS[0]`/`FILLS[0]` - the *oldest* record - under headings reading "Letzte Fahrt"; +it now sorts by timestamp. + +**3. `--fg3` on light ground was 2.92:1** against the page and 3.26:1 against a tile - too little +for a glance in sunlight. Darkened to `#6B6B70` (4.75:1 / 5.30:1) in `audi-dashboard-ios.css` and +`design-system`'s day theme; the night value stays `#8E8E93` (already 5.86:1). Verified live in +both: day `#6B6B70`, night unchanged. + +**4. The type scale - and here measuring first changed the fix entirely.** The audit counted +*declared* sizes and found fourteen distinct values below 18px, which reads as a badly fragmented +scale. Measuring the **effective** `font-size` of every text element across eleven panel views told +a different story: a clean seven-step backbone (11 / 12.5 / 13 / 14 / 15 / 16 / 17) already carried +**96%** of 621 elements, with 27 stragglers sitting one notch beside a real step. Most of the +declared values never reached the screen at all, because the always-loaded iOS overlay overrides +them - the same trap recorded twice before in this file. + +So the fix is narrow, not a restructure: snap the stragglers onto the nearest step +(11.5 -> 12.5, 12 -> 12.5, 13.5 -> 13, 14.5 -> 14, 16.5 -> 16), applied as one mechanical +substitution across panel CSS, panel inline styles, companion-app CSS and `design-system` at once, +so the two sides cannot drift apart on it. Nothing moves more than 1px. Also fixed: `` in the +accordion head had no rule of its own and inherited the UA's `smaller` (0.833x) - 10.4px, below the +11px floor this project took from Apple's guidance; it now has an explicit 11px. + +Deliberately untouched: `font-size: max(11px, .32em)` on the unit beside the large figures. Its 12.8 +and 14.72px readings are that rule scaling with a 40px and a 46px figure, which is the point of it. + +Re-measured afterwards across the same eleven views: **11 / 12.5 / 13 / 14 / 15 / 16 / 17**, nothing +else, plus the two intentional relative values. Fourteen values down to seven. + +**5. The Ubersicht carried its two heaviest tiles in front of its lightest.** Without a stored photo +the placeholder still occupied 120px directly above the range - the most important number on the +page - and "Zuletzt", two short and frequently read lines, sat below a 190px map and therefore below +the fold on a phone. Placeholder narrowed to 72px (kept, not removed: it is the only sign that this +image slot exists), and "Zuletzt" moved in front of the location card in both codebases. + +**Found while verifying, fixed too:** the app drew the Ubersicht service chevron as the text +character "›" at 15px, where the panel draws `.go`, a 6x10 SVG polyline at stroke-width 1.4 - +visibly a different shape and weight from every other chevron in the app. Now the same SVG. + +**Also fixed, from the owner's report "ubersicht/karte: hier stimmt ios noch nicht zum panel":** the +location preview drew the wrong pin. `fahrzeugMarkerSVG()` in the panel switches icon sets by size - +at or above 34px the 48-grid drawing, below it the 24-grid one, which is drawn for small sizes. The +app always used the 48-grid one, so the 32px preview showed a drawing meant to be seen at 38px+. The +app now mirrors the same switch, and the preview's map background is pinned to Leaflet's own #ddd, +which is what the panel actually shows (its own `var(--tile)` declaration loses to +`.leaflet-container`). Verified byte-identical afterwards: same marker markup (1087 characters, same +hash), same `translate3d(172px, 95px, 0)`, same drop-shadow, same colour. + +Verified: `node --check` on the panel bundle, `design-system` rebuilt, companion-app `tsc --noEmit` +clean and 147/147 green after every step, `npm run build` clean. Live at 375x812 the Ubersicht of +both now yields the same text and the same size distribution (11x1, 12.5x3, 12.8x1, 13x8, 15x3, +16x3) and the same tile order. Manifest `2026.8.30.6`, then `2026.8.30.7` once the chevron fix +changed the app after the first bundle - two different bundles must never share one version number. +`npm run ota` rebuilt (`bundle.zip` 256.124 bytes, sha256 +`556b8e7b43277366921e927ce56f8f748e0b7e5efaf021040fcfd99f0027e8c2`), redeployed to `audi_ha_test`, +clean restart. + +**Header padding: the one place where copying the panel's number was wrong (2026.8.30.8).** The +owner asked why the iOS app shows a large gap above the headline. Measured: 105px, made of a 49px +"Diese App ist älter als der Server" band - the message itself, not a layout fault - and 56px of +header padding. That 56px is a faithful copy of the panel (`.topbar{padding:56px 20px 4px 16px}` in +the iOS overlay, confirmed live). It is also the one case found so far where copying the panel +exactly produces a *different* result: in the panel those 56px sit below Home Assistant's own header +bar, while the native shell has nothing above them but the status bar. Same number, different +picture. On the owner's instruction the added padding is gone; `.dm-kopf`/`.dm-kopf--unterseite` now +carry `env(safe-area-inset-top, 0px)` alone, so the status bar stays clear on the device while the +headline starts at the top everywhere without an inset. Deliberate, disclosed divergence from the +panel - do not "fix" it back by matching the number again. + +**Committed and pushed** at the end of this session, after rebasing onto `25c0d07` (an `.ipa` refresh +pushed from elsewhere, no overlap). --- diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index dfa7ce9..c74648d 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -80,6 +80,9 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) { route={route} geheZu={geheZu} tabBeschriftung={tabBeschriftung} + statusStand={daten.statusStand} + // Ziehen zum Aktualisieren gibt es im Panel nur auf der Übersicht. + beiAktualisieren={route.name === "home" ? daten.jetztAktualisieren : undefined} hinweis={ Promise) | undefined /** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */ children: ReactNode } +/** Ziehen zum Aktualisieren, 1:1 nach der ptr-Logik des Panels. */ +const PTR_SCHWELLE = 56 + +function useZiehenAktualisieren( + inhalt: RefObject, + beiAktualisieren: (() => Promise) | undefined, +) { + const [zug, setzeZug] = useState(0) + const [laedt, setzeLaedt] = useState(false) + const start = useRef(null) + + if (!beiAktualisieren) { + return { zug: 0, laedt: false, handler: {} as Record } + } + + const beenden = () => { + if (start.current === null) return + start.current = null + if (zug >= PTR_SCHWELLE) { + setzeLaedt(true) + void beiAktualisieren().finally(() => { + setzeLaedt(false) + setzeZug(0) + }) + } else { + setzeZug(0) + } + } + + return { + zug, + laedt, + handler: { + onPointerDown: (e: ReactPointerEvent) => { + if (!e.isPrimary || laedt) return + if ((inhalt.current?.scrollTop ?? 0) > 0) return + start.current = e.clientY + setzeZug(0) + }, + onPointerMove: (e: ReactPointerEvent) => { + if (start.current === null || !e.isPrimary) return + const dy = e.clientY - start.current + if (dy > 0 && (inhalt.current?.scrollTop ?? 0) <= 0) { + // Widerstand wie bei iOS: halbe Strecke, gedeckelt bei 90px. + setzeZug(Math.min(90, dy * 0.5)) + } else { + start.current = null + setzeZug(0) + } + }, + onPointerUp: beenden, + onPointerCancel: beenden, + }, + } +} + const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [ { key: "home", label: "Übersicht", icon: }, { key: "audi", label: "Mein Audi", icon: }, @@ -43,13 +106,33 @@ const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [ { key: "fuel", label: "Tanken", icon: }, ] -export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) { +export function Shell({ + route, + geheZu, + tabBeschriftung, + hinweis, + statusStand, + beiAktualisieren, + children, +}: ShellProps) { const breit = useBreiterBildschirm() const aktiverBereich = bereichVon(route.name) const zurueckZiel = ZURUECK[route.name] + const unterseite = !istHauptbereich(route.name) + const inhaltRef = useRef(null) + const ptr = useZiehenAktualisieren(inhaltRef, breit ? undefined : beiAktualisieren) + + // Der Text tickt mit, wie standAlterTicken() im Panel (Sekundentakt). + const [, neuZeichnen] = useState(0) + useEffect(() => { + if (route.name !== "home") return undefined + const uhr = window.setInterval(() => neuZeichnen((n) => n + 1), 1000) + return () => window.clearInterval(uhr) + }, [route.name]) + const kopf = ( -
+
{zurueckZiel && !breit ? ( ) : null} + {/* Kein Eyebrow: das Panel blendet seinen (`.eyebrow{display:none}` in + audi-dashboard-ios.css) auf Telefonbreite vollständig aus - der feste + Text "DataMetric360" stand hier über jedem Bildschirm, im Panel steht + dort nichts. */}
- DataMetric360
{TITEL[route.name]}
+ {/* Datenstand nur auf der Übersicht, wie `#sync` im Panel. */} + {route.name === "home" && ( +
+ + {standAlter(statusStand)} +
+ )}
{/* `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. */} - + Nur auf den Tab-Ebenen: auf Unterseiten blendet das Panel die Ringe + aus (`#marke`.style.display in render()), dort steht links der + Zurück-Pfeil. */} + {!unterseite ? ( + + ) : null}
) @@ -143,7 +237,25 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She
{hinweis} {kopf} -
{children}
+ {/* Ziehen zum Aktualisieren wie im Panel (`.ptr`): das Band wächst + während der Geste aus der Höhe 0 heraus. */} +
+ + + {ptr.laedt + ? "Aktualisieren …" + : ptr.zug >= 56 + ? "Loslassen zum Aktualisieren" + : "Ziehen zum Aktualisieren"} + +
+
+ {children} +
{ + return this.warteschlange.einreihen( + DIENST_DOMAIN, + "bild_hochladen", + { dateiname, daten_base64: datenBase64 }, + `Foto „${dateiname}" hochladen`, + ) + } + + bildLoeschen(dateiname: string): Promise { + return this.warteschlange.einreihen( + DIENST_DOMAIN, + "bild_loeschen", + { dateiname }, + `Foto „${dateiname}" löschen`, + ) + } + /** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */ fahrtAnlegen(felder: FahrtFelder): Promise { return this.warteschlange.einreihen( diff --git a/companion-app/src/daten/geokodierung.ts b/companion-app/src/daten/geokodierung.ts new file mode 100644 index 0000000..44a32c9 --- /dev/null +++ b/companion-app/src/daten/geokodierung.ts @@ -0,0 +1,70 @@ +/** + * Adresse → Koordinaten (Vorwärts-Geokodierung über Nominatim), mit Cache im + * localStorage. Gegenstück zur Rückwärts-Auflösung in `Standort.tsx`. + * + * Gebraucht wird sie für die Tankstellennadel auf dem Einzelbeleg: der Beleg + * trägt nur eine Anschrift, und das Backend füllt `station_lat`/`station_lon` + * nie — ohne diesen Schritt bliebe die Karte dort dauerhaft ohne Nadel. Das + * Panel macht es seit jeher genau so (`adresseAufloesen()`); der App fehlte + * beides, Karte wie Auflösung. + * + * Der Cache liegt bewusst im localStorage und nicht nur im Speicher: eine + * Tankstellenadresse ändert ihre Lage nicht, und Nominatim bittet in seinen + * Nutzungsbedingungen ausdrücklich darum, Ergebnisse zu behalten. Auch ein + * erfolgloser Treffer wird als `null` vermerkt, damit eine Adresse, die + * Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird — ein + * Netzfehler dagegen nicht, der ist kein „gibt es nicht". + */ + +export interface Koordinate { + lat: number + lon: number +} + +const CACHE_SCHLUESSEL = "dm360.geocache" + +function cacheLesen(): Record { + try { + const roh = localStorage.getItem(CACHE_SCHLUESSEL) + return roh ? (JSON.parse(roh) as Record) : {} + } catch { + return {} + } +} + +function cacheSchreiben(suche: string, treffer: Koordinate | null): void { + try { + const cache = cacheLesen() + cache[suche] = treffer + localStorage.setItem(CACHE_SCHLUESSEL, JSON.stringify(cache)) + } catch { + // Privatmodus oder volle Ablage — dann eben ohne Cache. + } +} + +export async function adresseAufloesen(adresse: string | null | undefined): Promise { + const suche = (adresse ?? "").trim() + if (!suche) return null + const cache = cacheLesen() + if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche] ?? null + + let treffer: Koordinate | null = null + try { + const antwort = await fetch( + `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`, + { headers: { "Accept-Language": "de" } }, + ) + const daten: unknown = await antwort.json() + if (Array.isArray(daten) && daten.length > 0) { + const erster = daten[0] as { lat?: string; lon?: string } + const lat = Number.parseFloat(erster.lat ?? "") + const lon = Number.parseFloat(erster.lon ?? "") + if (Number.isFinite(lat) && Number.isFinite(lon)) treffer = { lat, lon } + } + } catch { + // Netzfehler: nichts merken, damit der nächste Aufruf es erneut versucht. + return null + } + cacheSchreiben(suche, treffer) + return treffer +} diff --git a/companion-app/src/daten/statistik.test.ts b/companion-app/src/daten/statistik.test.ts index fc8a0ec..3bbd4a3 100644 --- a/companion-app/src/daten/statistik.test.ts +++ b/companion-app/src/daten/statistik.test.ts @@ -176,7 +176,8 @@ describe("jahresauswertung", () => { }) describe("nachJahrUndMonat", () => { - it("gruppiert absteigend, Neuestes zuerst", () => { + // Durchgehend absteigend, in beiden Anwendungen (Designprüfung 2026-08-30). + it("gruppiert durchgehend absteigend, Neuestes zuerst", () => { const eintraege = [ { id: "alt", ts: "2025-03-04T10:00:00" }, { id: "neu", ts: "2026-08-01T10:00:00" }, diff --git a/companion-app/src/daten/statistik.ts b/companion-app/src/daten/statistik.ts index 579db90..f7f7738 100644 --- a/companion-app/src/daten/statistik.ts +++ b/companion-app/src/daten/statistik.ts @@ -130,10 +130,25 @@ export interface Jahresauswertung { privatKm: number arbeitFahrten: number privatFahrten: number - jeZeitraum: Record + jeZeitraum: Record< + Zeitraum, + { km: number; stunden: number; liter: number; kosten: number; verbrauch: number | null } + > langzeit: Langzeitverbrauch } +/** + * Nach Distanz gewichteter Mittelwert der genäherten Fahrtverbräuche — + * `schnittFahrtVerbrauch()` im Panel. Die Kachel „Langzeitverbrauch" zeigt + * damit einen Näherungswert je Zeitraum; hier fehlte er bisher. + */ +export function schnittFahrtVerbrauch(fahrten: readonly Fahrt[]): number | null { + const mit = fahrten.filter((f) => f.verbrauch_l_100km != null && (f.distance_km ?? 0) > 0) + const km = summe(mit, (f) => f.distance_km ?? 0) + if (km <= 0) return null + return summe(mit, (f) => (f.verbrauch_l_100km ?? 0) * (f.distance_km ?? 0)) / km +} + export function jahresauswertung( fahrten: readonly Fahrt[], tankvorgaenge: readonly Tankvorgang[], @@ -150,6 +165,7 @@ export function jahresauswertung( stunden: summe(f, (x) => x.duration_s ?? 0) / 3600, liter: summe(t, (x) => x.liters ?? 0), kosten: summe(t, (x) => x.fuel_total_eur ?? 0), + verbrauch: schnittFahrtVerbrauch(f), } } @@ -205,6 +221,11 @@ export function nachJahrUndMonat( ): JahresGruppe[] { const jahre = new Map>>() + /* Durchgehend absteigend: Jahr, Monat und Zeile. Bis zur Designprüfung + 2026-08-30 liefen Monate und Zeilen aufsteigend (der Panel-Stand von + damals) — in einer Liste, deren Jahre absteigen, sind das zwei + Richtungen gleichzeitig, und die neueste Fahrt landete unten außerhalb + des sichtbaren Bereichs. Das Panel ist mitgezogen (neuesteZuerst()). */ const sortiert = eintraege .slice() .sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime()) diff --git a/companion-app/src/format.test.ts b/companion-app/src/format.test.ts index ecc0d7d..44a2ffc 100644 --- a/companion-app/src/format.test.ts +++ b/companion-app/src/format.test.ts @@ -18,7 +18,7 @@ describe("Zahlen im deutschen Format", () => { it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => { expect(deOderStrich(0)).toBe("0") - expect(deOderStrich(null)).toBe("—") + expect(deOderStrich(null)).toBe("–") }) }) @@ -32,8 +32,8 @@ describe("dauer", () => { }) it("zeigt einen Strich statt „0 min", () => { - expect(dauer(0)).toBe("—") - expect(dauer(null)).toBe("—") + expect(dauer(0)).toBe("–") + expect(dauer(null)).toBe("–") }) }) diff --git a/companion-app/src/format.ts b/companion-app/src/format.ts index b8914ec..34de300 100644 --- a/companion-app/src/format.ts +++ b/companion-app/src/format.ts @@ -14,9 +14,14 @@ export function eur(wert: number | null | undefined): string { return de(wert, 2) } -/** Wie de(), aber „—" statt „0" wenn nichts bekannt ist. */ +/* Fehlende Werte zeigt das Panel durchgängig als Halbgeviertstrich „–", nicht + als Geviertstrich „—" (30 Fundstellen gegen eine). Hier stand app-weit der + längere Strich — sichtbar breiter, sobald beide Anwendungen nebeneinander + liegen. */ + +/** Wie de(), aber „–" statt „0" wenn nichts bekannt ist. */ export function deOderStrich(wert: number | null | undefined, nachkomma = 0): string { - return wert === null || wert === undefined ? "—" : de(wert, nachkomma) + return wert === null || wert === undefined ? "–" : de(wert, nachkomma) } /** Summe über eine Liste. */ @@ -61,32 +66,32 @@ function alsDatum(wert: string | number | Date | null | undefined): Date | null export function datum(wert: string | number | Date | null | undefined): string { const d = alsDatum(wert) - return d ? DATUM_KURZ.format(d) : "—" + return d ? DATUM_KURZ.format(d) : "–" } export function datumLang(wert: string | number | Date | null | undefined): string { const d = alsDatum(wert) - return d ? DATUM_LANG.format(d) : "—" + return d ? DATUM_LANG.format(d) : "–" } export function uhrzeit(wert: string | number | Date | null | undefined): string { const d = alsDatum(wert) - return d ? UHRZEIT.format(d) : "—" + return d ? UHRZEIT.format(d) : "–" } export function datumZeit(wert: string | number | Date | null | undefined): string { const d = alsDatum(wert) - return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "—" + return d ? `${DATUM_KURZ.format(d)}, ${UHRZEIT.format(d)} Uhr` : "–" } export function monatJahr(wert: string | number | Date | null | undefined): string { const d = alsDatum(wert) - return d ? MONAT_JAHR.format(d) : "—" + return d ? MONAT_JAHR.format(d) : "–" } /** Dauer in Sekunden als „2 h 14 min" bzw. „14 min". */ export function dauer(sekunden: number | null | undefined): string { - if (!sekunden || sekunden <= 0) return "—" + if (!sekunden || sekunden <= 0) return "–" const minutenGesamt = Math.round(sekunden / 60) const stunden = Math.floor(minutenGesamt / 60) const minuten = minutenGesamt % 60 @@ -94,6 +99,23 @@ export function dauer(sekunden: number | null | undefined): string { return `${stunden} h ${String(minuten).padStart(2, "0")} min` } +/** + * Alter des letzten Datenstands als „vor 3 Std." — wortgleich mit + * `standAlterText()` im Panel, das diesen Text in der Kopfzeile der Übersicht + * neben dem Aktualisieren-Symbol zeigt. + */ +export function standAlter(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + if (!d) return "unbekannt" + const sekunden = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)) + if (sekunden < 60) return `vor ${sekunden} Sek.` + const minuten = Math.floor(sekunden / 60) + if (minuten < 60) return `vor ${minuten} Min.` + const stunden = Math.floor(minuten / 60) + if (stunden < 24) return `vor ${stunden} Std.` + return `vor ${Math.floor(stunden / 24)} Tage` +} + /** ISO-Tagesdatum (YYYY-MM-DD) für Vergleiche und Eingabefelder. */ export function isoTag(wert: string | number | Date | null | undefined = new Date()): string { const d = alsDatum(wert) @@ -101,3 +123,14 @@ export function isoTag(wert: string | number | Date | null | undefined = new Dat const versatz = d.getTimezoneOffset() * 60_000 return new Date(d.getTime() - versatz).toISOString().slice(0, 10) } + +/** + * Datum und Uhrzeit mit „ · " getrennt — so setzt das Panel den Zeitstempel in + * der Batterie-Messwertliste (`dedat(...) + " · " + toLocaleTimeString(...)`). + * `datumZeit()` daneben bleibt die Komma-Schreibweise für die Stellen, die sie + * schon benutzen. + */ +export function datumUndZeit(wert: string | number | Date | null | undefined): string { + const d = alsDatum(wert) + return d ? `${DATUM_KURZ.format(d)} · ${UHRZEIT.format(d)} Uhr` : "–" +} diff --git a/companion-app/src/navigation.ts b/companion-app/src/navigation.ts index 36c1860..5298ef8 100644 --- a/companion-app/src/navigation.ts +++ b/companion-app/src/navigation.ts @@ -73,29 +73,35 @@ export const ZURUECK: Partial> = { live: "home", } -/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */ +/** + * Überschrift je Seite — wortgleich mit dem Kopf, den `render()` im Panel + * setzt (`head[1]`, audi-dashboard-app.js). Neun Titel wichen davon ab + * (Fahrzeugdaten/Batterie/Versicherung & Steuer/Beitrag/Notruf/Werkstatt/ + * Wartungsplan/Fahrt/Tankvorgang); das waren keine Entscheidungen, sondern + * Drift. `live` hat keine Panel-Entsprechung (companion-app-eigener Screen). + */ export const TITEL: Record = { home: "Übersicht", audi: "Mein Audi", trips: "Fahrten", stat: "Statistik", fuel: "Tanken", - ident: "Fahrzeugdaten", - battverlauf: "Batterie", + ident: "Identität und Technik", + battverlauf: "Batteriespannung", battliste: "Messwerte", service: "Service", - werkstatt: "Werkstatt", - sbuch: "Wartungsplan", + werkstatt: "Autohaus", + sbuch: "Eintrag", reifen: "Reifen", - vers: "Versicherung & Steuer", - beitrag: "Beitrag", + vers: "Versicherung/Steuer", + beitrag: "Beitrag anpassen", vertrag: "Vertrag", vertragsdetails: "Vertragsdetails", schutz: "Schutzbrief", - notruf: "Notruf", + notruf: "Rufnummern", steuer: "Kfz-Steuer", - trip: "Fahrt", - fill: "Tankvorgang", + trip: "Einzelfahrt", + fill: "Einzelbeleg", sicherheit: "Fahrzeugstatus", tuerenklappen: "Türen und Klappen", standort: "Standort", diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx index e3a748f..a372de0 100644 --- a/companion-app/src/screens/Batterie.tsx +++ b/companion-app/src/screens/Batterie.tsx @@ -19,7 +19,7 @@ import { ENTITAETEN } from "../api" import { useDaten } from "../daten/DatenKontext" import { datum, de } from "../format" import type { SeitenName } from "../navigation" -import { SymbolListe } from "../symbole" +import { SymbolBatterie, SymbolListe } from "../symbole" import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" interface Tageswert { @@ -112,8 +112,57 @@ function ladezustand(spannung: number): number | null { return 100 } +/** `bvSocLabel()` im Panel. */ +function socLabel(soc: number): string { + return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch" +} + +/** + * `bvSohBewertung()` im Panel: Trend über die früheren gegen die späteren + * Messwerte, mit denselben Schwellen und demselben Wortlaut. Fehlte hier — die + * Bewertungskachel gab es gar nicht, stattdessen stand der Trend als nackte + * Wertzeile ohne Einordnung. + */ +const TAG_MS = 86_400_000 +function sohBewertung( + punkte: { t: number; v: number }[], +): { status: "ok" | "warn" | "bad" | null; text: string; delta: number } { + if (punkte.length < 6) + return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung.", delta: 0 } + const spanneTage = (punkte[punkte.length - 1]!.t - punkte[0]!.t) / TAG_MS + if (spanneTage < 60) + return { + status: null, + text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig).", + delta: 0, + } + const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS + const alt = punkte.filter((p) => p.t <= punkte[0]!.t + fensterMs) + const neu = punkte.filter((p) => p.t >= punkte[punkte.length - 1]!.t - fensterMs) + const mittel = (l: { v: number }[]) => l.reduce((s, p) => s + p.v, 0) / l.length + const delta = mittel(neu) - mittel(alt) + const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad" + const text = + delta >= -0.05 + ? "Stabil - kein auffälliger Rückgang der Ruhespannung." + : delta >= -0.15 + ? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen." + : "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten." + return { status, text, delta } +} + +function statusFarbe(status: "ok" | "warn" | "bad" | null): string { + return status === "ok" + ? "var(--ok)" + : status === "warn" + ? "var(--warn)" + : status === "bad" + ? "var(--bad)" + : "var(--fg3)" +} + export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) { - const { api, fahrzeug } = useDaten() + const { api } = useDaten() const [verlauf, setzeVerlauf] = useState(null) const [fehler, setzeFehler] = useState(false) @@ -153,60 +202,79 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) { const letzter = messwerte[messwerte.length - 1]! const soc = letzter.min != null ? ladezustand(letzter.min) : null + const stufen = farbstufenLesen() + const punkte = messwerte + .filter((t) => t.min != null && t.min_ts) + .map((t) => ({ t: new Date(t.min_ts!).getTime(), v: t.min! })) + const soh = sohBewertung(punkte) - // Trend als Vergleich früher gegen später — keine echte Kapazitätsmessung. - const haelfte = Math.floor(messwerte.length / 2) - const mittel = (liste: Tageswert[]) => - liste.length > 0 ? liste.reduce((s, t) => s + (t.min ?? 0), 0) / liste.length : null - const frueher = mittel(messwerte.slice(0, haelfte)) - const spaeter = mittel(messwerte.slice(haelfte)) - const trend = frueher != null && spaeter != null ? spaeter - frueher : null - + /* Aufbau wie vBatterieverlauf(): eine Kachel „Verlauf" mit Diagramm und + Ruhespannungs-Skala, darunter eine Kachel „Bewertung" mit Ladezustand und + Gesundheitszustand. Hier stand bis 2026-08-30 eine einzelne Kachel + „Ruhespannung" mit großer Zahl und vier Wertzeilen — die Farbskala, die + Bewertungskachel und der Bewertungstext fehlten ganz, dafür gab es eine + Zeile „Aktuell gemeldet", die das Panel hier nicht führt. */ return ( <>
- Ruhespannung + Verlauf geheZu("battliste")}>
-
-
- {letzter.min != null ? de(letzter.min, 2) : "—"} - V -
-
+
+ + Ruhespannung +
+
+
+ 11,6 V · leer + 12,2 V · 50 % + 12,8 V · voll +
+ + + + Bewertung - - = 0 ? "+" : ""}${de(trend, 2)} V` : "—"} - /> - + + {de(letzter.min, 1)} V · {de(soc)} % · {socLabel(soc)} + + ) : ( + unbekannt + ) + } + /> + + + {soh.delta >= 0 ? "+" : ""} + {de(soh.delta, 2)} V + + ) : ( + - + ) } /> } /> -

- Der Ladezustand wird aus dem Tagesminimum geschätzt — das ist die Spannung kurz nach dem - Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der - späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung. -

+ {soh.text}
) @@ -221,12 +289,35 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) { 320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */ +/** Hält einen Zoomausschnitt innerhalb des vorhandenen Zeitraums. */ +function begrenzen( + bereich: [number, number], + voll: [number, number], +): [number, number] { + const spanne = Math.min(bereich[1] - bereich[0], voll[1] - voll[0]) + let von = bereich[0] + if (von < voll[0]) von = voll[0] + if (von + spanne > voll[1]) von = voll[1] - spanne + return [von, von + spanne] +} + +const BV_H = 170 +const BV_ML = 40 +const BV_MR = 10 +const BV_MT = 10 +const BV_MB = 12 + function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) { - const hoehe = 120 - const rand = 8 + const hoehe = BV_H const svgRef = useRef(null) const [breite, setzeBreite] = useState(320) const [gewaehlt, setzeGewaehlt] = useState(null) + /* Zoomen und Verschieben wie im Panel (bvDomain/bvZeiger/bvGestStart): + zwei Finger spreizen den Zeitausschnitt, einer verschiebt ihn, der Knopf + stellt ihn zurück. Fehlte hier ganz — die Kurve war unveränderlich. */ + const [ausschnitt, setzeAusschnitt] = useState<[number, number] | null>(null) + const zeiger = useRef(new Map()) + const gestStart = useRef<{ domain: [number, number]; mitte: number; spanne: number } | null>(null) useEffect(() => { const el = svgRef.current @@ -247,16 +338,28 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) { const groesster = 15 const spanne = groesster - kleinster - const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand) + // Zeitachse: echte Zeitstempel statt gleichmäßiger Indexschritte, sonst + // ließe sich der Ausschnitt nicht sinnvoll zoomen. + const zeitVon = (t: Tageswert) => new Date(t.min_ts ?? t.datum).getTime() + const vollDomain: [number, number] = + werte.length >= 2 + ? [zeitVon(werte[0]!), zeitVon(werte[werte.length - 1]!)] + : [0, 1] + const domain = ausschnitt ?? vollDomain + const zeitSpanne = Math.max(1, domain[1] - domain[0]) + + const x = (i: number) => + BV_ML + + ((zeitVon(werte[i]!) - domain[0]) / zeitSpanne) * (breite - BV_ML - BV_MR) // Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb // von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche. const y = (v: number) => { const anteil = Math.max(0, Math.min(1, (v - kleinster) / spanne)) - return hoehe - rand - anteil * (hoehe - 2 * rand) + return hoehe - BV_MB - anteil * (hoehe - BV_MT - BV_MB) } - const linie = werte - .map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`)) + const punkteText = werte + .map((t, i) => (t.min == null ? null : `${x(i).toFixed(1)},${y(t.min).toFixed(1)}`)) .filter(Boolean) .join(" ") @@ -264,15 +367,84 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) { const el = svgRef.current if (!el || werte.length === 0) return const kasten = el.getBoundingClientRect() - const relativ = ereignis.clientX - kasten.left - rand - const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1) - const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt))) - setzeGewaehlt((vorher) => (vorher === index ? null : index)) + const anteil = (ereignis.clientX - kasten.left - BV_ML) / Math.max(1, breite - BV_ML - BV_MR) + const zeit = domain[0] + anteil * zeitSpanne + let bester = 0 + let abstand = Infinity + werte.forEach((t, i) => { + const a = Math.abs(zeitVon(t) - zeit) + if (a < abstand) { + abstand = a + bester = i + } + }) + setzeGewaehlt((vorher) => (vorher === bester ? null : bester)) + } + + const zeigerRunter = (e: React.PointerEvent) => { + zeiger.current.set(e.pointerId, e.clientX) + if (zeiger.current.size === 2) { + const [a, b] = [...zeiger.current.values()] + gestStart.current = { + domain: [domain[0], domain[1]], + mitte: (a! + b!) / 2, + spanne: Math.abs(a! - b!) || 1, + } + } else if (zeiger.current.size === 1) { + gestStart.current = { domain: [domain[0], domain[1]], mitte: e.clientX, spanne: 0 } + } + } + + const zeigerBewegt = (e: React.PointerEvent) => { + if (!zeiger.current.has(e.pointerId) || !gestStart.current) return + zeiger.current.set(e.pointerId, e.clientX) + const nutzbar = Math.max(1, breite - BV_ML - BV_MR) + const start = gestStart.current + const startSpanne = start.domain[1] - start.domain[0] + if (zeiger.current.size >= 2) { + const [a, b] = [...zeiger.current.values()] + const faktor = start.spanne / Math.max(1, Math.abs(a! - b!)) + const neueSpanne = Math.max( + 3600_000, + Math.min(vollDomain[1] - vollDomain[0], startSpanne * faktor), + ) + const mitteZeit = start.domain[0] + (startSpanne * (start.mitte - BV_ML)) / nutzbar + setzeAusschnitt(begrenzen([mitteZeit - neueSpanne / 2, mitteZeit + neueSpanne / 2], vollDomain)) + } else { + const verschub = ((start.mitte - e.clientX) / nutzbar) * startSpanne + setzeAusschnitt( + begrenzen([start.domain[0] + verschub, start.domain[1] + verschub], vollDomain), + ) + } + } + + const zeigerHoch = (e: React.PointerEvent) => { + zeiger.current.delete(e.pointerId) + if (zeiger.current.size === 0) gestStart.current = null } const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null const stufen = farbstufenLesen() + const kurz = (wert?: string) => + wert + ? new Date(wert).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + }) + : "" + + /* Gitterlinien mit Achsenbeschriftung und die weiche Flächenfüllung unter der + Linie fehlten hier ganz — das Panel zeichnet beide (bvZeichnen()), samt + dem Zeitraum unter der Fläche. Ohne sie stand die Kurve ohne jeden + Maßstab im Raum. */ + const flaeche = + punkteText.length > 0 && werte.length >= 2 + ? `M${x(0).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} L${punkteText.split(" ").join(" L")} L${x( + werte.length - 1, + ).toFixed(1)},${(hoehe - BV_MB).toFixed(1)} Z` + : "" return ( <> @@ -283,8 +455,45 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) { role="img" aria-label={`Spannungsverlauf über ${werte.length} Messungen`} onClick={punktWaehlen} + onPointerDown={zeigerRunter} + onPointerMove={zeigerBewegt} + onPointerUp={zeigerHoch} + onPointerCancel={zeigerHoch} > - + + + + + + + {[0, 1, 2, 3].map((i) => { + const v = kleinster + (spanne * i) / 3 + const yy = y(v) + return ( + + + + {de(v, 1)} + + + ) + })} + {flaeche && } + {werte.map((t, i) => t.min == null ? null : ( +
+ + {kurz(new Date(domain[0]).toISOString())} – {kurz(new Date(domain[1]).toISOString())} + + +
{aktiv && ( -

+

{[ zeitpunkt ? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` @@ -310,7 +527,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) { ] .filter(Boolean) .join(" · ")} -

+
)} ) diff --git a/companion-app/src/screens/BatterieListe.tsx b/companion-app/src/screens/BatterieListe.tsx index 38b936c..afd7d65 100644 --- a/companion-app/src/screens/BatterieListe.tsx +++ b/companion-app/src/screens/BatterieListe.tsx @@ -19,7 +19,7 @@ import { Pill, Tile } from "@audi-dash/ui" import { ENTITAETEN } from "../api" import { useDaten } from "../daten/DatenKontext" -import { datum, datumZeit, de } from "../format" +import { datum, datumUndZeit, de } from "../format" import { Leerzustand } from "./bausteine" import { AGM_RUHE_MAX_V } from "./Batterie" import { Zeilenmenue } from "./Zeilenmenue" @@ -86,11 +86,11 @@ export function BatterieListe() { {de(t.min!, 2)} V - {t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "—"} + {t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "–"} - {t.min_ts ? datumZeit(t.min_ts) : datum(t.datum)} + {t.min_ts ? datumUndZeit(t.min_ts) : datum(t.datum)} {t.min! > AGM_RUHE_MAX_V && Generatorspannung} diff --git a/companion-app/src/screens/Bild.tsx b/companion-app/src/screens/Bild.tsx index 89589ec..a4fd2e6 100644 --- a/companion-app/src/screens/Bild.tsx +++ b/companion-app/src/screens/Bild.tsx @@ -16,21 +16,27 @@ export function Bild({ beschreibung, name, groesse, + dateiZeigen, }: { datei: string | null | undefined beschreibung: string name: string groesse?: "default" | "mini" | "round" + /** Zeigt den Dateinamen statt der Handlungsaufforderung - so macht es das + Panel auf der Einstellungen-Seite (dateiZeigen in bildMitPlatzhalter()). */ + dateiZeigen?: boolean }) { const url = bildUrl(datei) - // Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten - // Miniaturansicht liefe er über den Rand hinaus. - const mitDateiname = groesse !== "mini" && groesse !== "round" + // Unter der Bezeichnung steht im Panel die Handlungsaufforderung, nicht der + // Dateiname (`.platzhalter-aktion` zeigt "Foto hinzufügen"; + // `.platzhalter-datei` ist dort ausdrücklich ausgeblendet). In der 78px + // breiten Miniaturansicht bleibt kein Platz dafür. + const mitAktion = groesse !== "mini" && groesse !== "round" return ( diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 3c2ec3f..dbe5806 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -14,11 +14,10 @@ import { eigeneVersion } from "../daten/appVersion" import { useDaten } from "../daten/DatenKontext" import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota" import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" -import { datumZeit, de, isoTag } from "../format" -import { useTheme } from "../theme" -import { Wertzeile, Werteliste, bestaetigen } from "./bausteine" -import { Bild } from "./Bild" -import { BILDPLAETZE, markenlogoUrl } from "./bilder" +import { datumZeit, isoTag } from "../format" +import { useBoden, useTheme } from "../theme" +import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine" +import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder" import { DatensatzPopup } from "./DatensatzPopup" import { HistorienImport } from "./HistorienImport" import type { ServicebuchEintrag } from "./Service" @@ -49,6 +48,13 @@ export function Einstellungen({ neuLaden, } = useDaten() const [theme, themeSetzen] = useTheme() + const [boden, setzeBoden] = useBoden() + // "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den + // Knopf darunter (einrichtenOffen in vEinst()). + const [einrichtenOffen, setzeEinrichtenOffen] = useState(false) + // Erstzulassung liegt am Fahrzeug, nicht an den Einstellungen - deshalb ein + // eigener Entwurf, der beim Speichern mitgeschrieben wird. + const [erstzulassung, setzeErstzulassung] = useState(null) const [entwurf, setzeEntwurf] = useState(null) const [laeuft, setzeLaeuft] = useState(false) const [gespeichert, setzeGespeichert] = useState(false) @@ -161,6 +167,13 @@ export function Einstellungen({ if (!einstellungen || !fahrzeug) return null const werte = entwurf ?? einstellungen + const erstzulassungWert = erstzulassung ?? fahrzeug.erstzulassung + // Bei Winterraedern zeigt die Seitenansicht das Winterfoto - wie bildInfo() + // im Panel, das hier dieselbe Vorschau liefert. + const gewaehltesBild = + werte.startbild === "seitenansicht.webp" && fahrzeug.reifen.aktiv === "Winter" + ? { datei: SEITE_WINTER.datei, beschreibung: SEITE_WINTER.beschreibung } + : (BILDPLAETZE.find((b) => b.datei === werte.startbild) ?? BILDPLAETZE[0]!) const aendern = (teil: Partial) => setzeEntwurf({ ...werte, ...teil }) @@ -170,8 +183,12 @@ export function Einstellungen({ setzeLaeuft(true) setzeGespeichert(false) try { - await profilSpeichern({ einstellungen: entwurf }) + await profilSpeichern({ + einstellungen: entwurf, + ...(erstzulassung !== null ? { fahrzeug: { ...fahrzeug, erstzulassung } } : {}), + }) setzeEntwurf(null) + setzeErstzulassung(null) setzeGespeichert(true) } finally { setzeLaeuft(false) @@ -243,99 +260,97 @@ export function Einstellungen({ return ( <> + {/* Aufbau, Reihenfolge und Wortlaut wie vEinst(): „Fahrzeug einrichten" + (aufklappbar), Darstellung, Bild der Übersicht, Kraftstoff-Rabatt, + Fahrten zusammenfassen, Fahrzeugprofil, Backup, Version, + Integration-Update. Vorher standen die Abschnitte in anderer + Reihenfolge und unter eigenen Namen („Tankrabatt", „Sicherung", + „Vergangene Daten", „Bild auf der Übersicht"). */} - Fahrzeug - - aendern({ fahrzeugtitel: e.target.value })} - /> - - - aendern({ fahrzeugzusatz: e.target.value })} - /> - - - aendern({ kennzeichen: e.target.value })} - /> - - - aendern({ tankvolumen: Number(e.target.value) || 0 })} - /> - - - - - {/* Panel-Wortlaut: "Fahrten zusammenfassen" / "Pause bis" - war hier - als "Fahrten"/"Pause zwischen zwei Fahrten" eigenständig - formuliert, brach dadurch in der 16px-Label-Spalte (Parität-Audit - 2026-08-30, Feld-Label-Korrektur) Wort für Wort um. */} - Fahrten zusammenfassen - - aendern({ pauseMin: Number(e.target.value) || 0 })} - /> - - - aendern({ nachtVon: e.target.value })} - /> - - - aendern({ nachtBis: e.target.value })} - /> - -

- Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und - nicht als zwei — etwa bei einem Tankstopp. -

+ Fahrzeug einrichten + {einrichtenOffen && ( + <> + + aendern({ fahrzeugtitel: e.target.value })} + /> + + + aendern({ kennzeichen: e.target.value })} + /> + + + setzeErstzulassung(e.target.value)} + /> + + + aendern({ ausfuehrung: e.target.value })} + /> + + {/* „Setup — Sensoren zuordnen" bleibt dem Panel vorbehalten: die + Zuordnung von Home-Assistant-Entitäten setzt eine angemeldete + HA-Sitzung voraus, die diese App nicht hat. */} + setzeImportOffen(true)}> + Daten importieren aus Home Assistant + + + Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum + nachträglich an — aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren + bereits aufgezeichnet hat. + + + )} + setzeEinrichtenOffen((offen) => !offen)} + > + {einrichtenOffen ? "Fertig" : "Einrichten"} +
Darstellung - - themeSetzen(neu as "nacht" | "tag")} - /> - - + themeSetzen(neu as "nacht" | "tag")} + /> + + + Aus zeigt nur die Symbole und gibt etwas Höhe frei + + + + + + Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten + - Bild auf der Übersicht - + Bild der Übersicht + -
- {BILDPLAETZE.map((platz) => ( - - ))} + + Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen. + + {/* Vorher stand hier ein Raster aller sieben Miniaturen und der Hinweis, + Fotos seien nur über Home Assistant zu pflegen — das Panel zeigt + genau die gewählte Ansicht groß und lädt direkt hoch. */} +
+
-

- Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest - vergeben — der Server nimmt nur diese an. -

- Tankrabatt + Kraftstoff-Rabatt @@ -379,7 +392,9 @@ export function Einstellungen({ e.currentTarget.style.display = "none" }} /> - {werte.markenname}-Rabatt aktiv + {/* Das Panel nennt den Schalter "SmartDeal aktiv" - fest, nicht + aus dem Markennamen gebaut. */} + SmartDeal aktiv } last={!fahrzeug.smartdeal.aktiv} @@ -394,7 +409,7 @@ export function Einstellungen({ }, }) } - aria-label="Tankrabatt aktiv" + aria-label="SmartDeal aktiv" /> {fahrzeug.smartdeal.aktiv ? ( @@ -414,31 +429,31 @@ export function Einstellungen({ } /> -

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

) : ( -

Ersparnis und Bildmarke werden ausgeblendet.

+ + Ersparnis und Bildmarke werden ausgeblendet + )}
- {/* Nachträglicher Import aus dem HA-Verlauf. Steht bewusst vor - „Sicherung": beides holt Daten von außen in die App, und der Import - ist der Schritt, den man einmal nach der Einrichtung braucht. */} + {/* „Nacht beginnt"/„Nacht endet" sind entfallen: das Panel fuehrt sie + in den Einstellungen nicht, die Nachtgrenze steht dort nur als + Erlaeuterung auf der Statistik-Seite. */} - Vergangene Daten -

- Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum - nachträglich an — aus dem Verlauf, den Home Assistant zu den zugeordneten Sensoren - bereits aufgezeichnet hat. -

-
- setzeImportOffen(true)}> - Daten importieren aus Home Assistant - -
+ Fahrten zusammenfassen + + aendern({ pauseMin: Number(e.target.value) || 0 })} + /> + + + Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, bleibt es eine + zusammenhängende Fahrt statt zweier +
- Sicherung - + Fahrzeugprofil +

+ Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, + Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der + Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine + Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt + unangetastet. +

+
+ setzeDatensatzPopup("sichern")}> + Datensatz sichern + + setzeDatensatzPopup("laden")}>Datensatz laden +
+
+ + {/* Wortlaut, Reihenfolge und Knopfbeschriftungen wie im Panel: die + Kachel hiess hier „Sicherung", der Erlaeuterungstext fehlte, und die + Knoepfe waren eigenstaendig formuliert („Jetzt sichern", „Auf dieses + Geraet laden", „Von diesem Geraet einspielen"). */} + + Backup + + Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" + legt eine Kopie auf dem Server ab, "Exportieren"/"Importieren" + übertragen den aktuellen Gesamtstand als einzelne Datei. + + {werte.letztesBackup && ( + + Letztes Backup: {datumZeit(werte.letztesBackup)} + + )} + void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")}> + Backup jetzt erstellen + + - - } - /> -
- - void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt") - } - > - Jetzt sichern - - Auf dieses Gerät laden - backupDateiwahl.current?.click()} - disabled={backupImportLaeuft} - > - {backupImportLaeuft ? "Lade …" : "Von diesem Gerät einspielen"} - -
+ Backup exportieren + backupDateiwahl.current?.click()} + disabled={backupImportLaeuft} + > + {backupImportLaeuft ? "Lade …" : "Backup importieren"} + {backupImportFehler}

}
- - Fahrzeugprofil -

- Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, - Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der - Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine - Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt - unangetastet. -

-
- setzeDatensatzPopup("sichern")}> - Datensatz sichern - - setzeDatensatzPopup("laden")}>Datensatz laden -
-
{otaMoeglich() && ( @@ -544,7 +560,7 @@ export function Einstellungen({ /> } /> -

+

Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon nicht betroffen — die liegen in Home Assistant.

@@ -556,7 +572,7 @@ export function Einstellungen({ {otaFehler &&

{otaFehler}

} ) : ( -

+

{otaBuendel ? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).` : "Der Server hat gerade kein Update-Bündel hinterlegt."} @@ -590,20 +606,20 @@ export function Einstellungen({ {updateInstallierenLaeuft ? (

) : updateNeustartLaeuft ? (
) : integrationUpdate?.installiert ? ( <> -

+

Version {integrationUpdate.version} installiert - die neue Fassung wird erst nach einem Neustart geladen.

@@ -618,7 +634,7 @@ export function Einstellungen({ } /> -

+

Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon nicht betroffen. @@ -632,13 +648,13 @@ export function Einstellungen({ ) : updatePruefenLaeuft ? (

) : ( <> -

+

{integrationUpdate ? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).` : "Noch nicht geprüft."} @@ -658,21 +674,17 @@ export function Einstellungen({ !updateNeustartLaeuft &&

{integrationUpdate.fehler}

}
+ {/* Ohne Entsprechung im Panel - und zwar notwendigerweise: das Panel + laeuft innerhalb von Home Assistant und kennt keine Serveradresse und + kein Token, die man wechseln koennte. Bleibt deshalb bestehen (wie + der Willkommensbildschirm). Die beiden reinen Zaehlzeilen darueber + („Fahrten gespeichert"/„Tankvorgaenge gespeichert") sind entfallen - + die waren Zusatz ohne Panel-Vorbild. */} Zugang - - - - - } - /> -
- void abmelden()}> - Verbindung trennen - -
+ void abmelden()}> + Verbindung trennen +
{gespeichert &&

Gespeichert.

} diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx index a87f18b..163465b 100644 --- a/companion-app/src/screens/FahrtDetail.tsx +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -11,12 +11,13 @@ import { useState } from "react" -import { ActionButton, Feld, Pill, Tile } from "@audi-dash/ui" +import { ActionButton, Feld, Fig, Pill, Tile } from "@audi-dash/ui" import type { Fahrt } from "../api" import { useDaten } from "../daten/DatenKontext" import { datum, dauer, de, deOderStrich, uhrzeit } from "../format" -import { Leerzustand, Wertzeile, Werteliste } from "./bausteine" +import type { SeitenName } from "../navigation" +import { Leerzustand, Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { FahrtFelderFormular, fahrtFelderAusFahrt, fahrtFelderAuswerten } from "./FahrtFelder" import { Karte } from "./Karte" @@ -28,8 +29,8 @@ function alsDatetimeLocal(iso: string): string { return new Date(d.getTime() - versatz).toISOString().slice(0, 16) } -export function FahrtDetail({ id }: { id: string | undefined }) { - const { fahrten } = useDaten() +export function FahrtDetail({ id, geheZu }: { id: string | undefined; geheZu: (name: SeitenName) => void }) { + const { fahrten, api } = useDaten() const fahrt = fahrten.find((f) => f.trip_id === id) const [bearbeitenOffen, setzeBearbeitenOffen] = useState(false) @@ -42,73 +43,31 @@ export function FahrtDetail({ id }: { id: string | undefined }) { ) } + const loeschen = () => { + if (!fahrt || !bestaetigen("Fahrt löschen?")) return + void api.fahrtLoeschen(fahrt.trip_id) + geheZu("trips") + } + if (bearbeitenOffen) { return setzeBearbeitenOffen(false)} /> } const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null + /* Aufbau 1:1 wie vTrip(): Karte, dann die randlose Distanz-Kachel mit der + 52px-Zahl und der Art-Pille darunter, dann eine einzige Wertezeilen-Kachel + (Start/Ziel/Start- und Endkilometer/Dauer/Verbrauch) mit „Werte + bearbeiten", zuletzt „Fahrt löschen". + + Vorher stand hier eine eigene Kopfzeile mit Datumsbereich und + Bearbeiten-Textknopf, die Werte in anderer Reihenfolge und unter anderen + Namen („Kilometerstand Beginn/Ende"), eine Zeile „Durchschnitt", die es im + Panel nicht gibt, zwei zusätzliche Erklärabsätze und Start/Ziel erst ganz + unten unter der Karte. */ return ( <> - -
- - {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr - - -
-
-
- {deOderStrich(fahrt.distance_km, 1)} - km -
-
- {fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"} - {fahrt.status === "offen" && noch offen} - {fahrt.source === "manual" && selbst eingetragen} -
-
- - - - - 0 - ? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h` - : "—" - } - /> - - - } - /> -
- - {fahrt.status === "offen" && ( -

- Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der - nächsten Fahrt — die Strecke wird dann automatisch ergänzt. -

- )} - - - Strecke + {hatPositionen ? ( )} + + + + Distanz +
+ +
+
+ {fahrt.art === "arbeitsweg" ? "Arbeitsweg" : "Privat"} +
+
+ + @@ -136,16 +108,37 @@ export function FahrtDetail({ id }: { id: string | undefined }) { wert={`${datum(fahrt.ts_end)} · ${uhrzeit(fahrt.ts_end)} Uhr`} zusatz={fahrt.end_address ?? "unbekannt"} /> + + + + } /> + setzeBearbeitenOffen(true)}>Werte bearbeiten + + void loeschen()}> + Fahrt löschen + ) } -/** Bearbeiten-Formular für eine bestehende Fahrt - dieselbe Feldgruppe wie - beim Neuanlegen (FahrtFelder.tsx), vorbelegt mit den aktuellen Werten. */ function FahrtBearbeiten({ fahrt, beiFertig }: { fahrt: Fahrt; beiFertig: () => void }) { const { api } = useDaten() const [start, setzeStart] = useState(alsDatetimeLocal(fahrt.ts_start)) diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx index 55afc83..ebe6d02 100644 --- a/companion-app/src/screens/Fahrten.tsx +++ b/companion-app/src/screens/Fahrten.tsx @@ -5,13 +5,13 @@ import { useState } from "react" -import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui" +import { Accordion, ActionButton, Feld, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" import { nachJahrUndMonat } from "../daten/statistik" -import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format" +import { datum, de, summe } from "../format" import type { SeitenName } from "../navigation" -import { Leerzustand } from "./bausteine" +import { Blattzeile, Leerzustand } from "./bausteine" import { FahrtFelderFormular, LEERE_FAHRT_FELDER, fahrtFelderAuswerten } from "./FahrtFelder" import type { FahrtFelderEingabe } from "./FahrtFelder" import { Zeilenmenue } from "./Zeilenmenue" @@ -28,88 +28,104 @@ function strecke(fahrt: { start_address?: string | null; end_address?: string | return `${a} → ${b}` } +/** artText() im Panel: erster Buchstabe groß, sonst unverändert. */ +function artText(art: string | null | undefined): string { + return art ? art.charAt(0).toUpperCase() + art.slice(1) : "" +} + export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { const { fahrten, api } = useDaten() const [formularOffen, setzeFormularOffen] = useState(false) const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start) + /* Aufbau 1:1 wie vTrips(): eine einzige Kachel um alle Jahre, die + Fahrtenzahl steht in der Jahreszeile (nicht als eigene Überschrift über + der Liste), die Monatszeile nennt nur den Monat, und das Formular steht + unter der Liste statt darüber. Die Zeilen selbst sind Blattzeilen mit der + Strecke als Hauptangabe und "Datum · Art" darunter — hier standen bisher + die Kilometer vorn und alles andere in einer langen Fußzeile, dazu zwei + Pillen (Arbeitsweg/offen), die es im Panel nicht gibt: die Art steht dort + im Zusatz der Beschriftung, der Status im Zusatz des Werts. */ return ( <> -
- - {fahrten.length === 0 - ? "Keine Fahrten" - : `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`} - - -
- - {formularOffen && setzeFormularOffen(false)} />} - - {fahrten.length === 0 && !formularOffen && ( - setzeFormularOffen(true) }} - /> - )} - - {gruppen.map((jahr) => ( - f.distance_km ?? 0))} km`} - defaultOpen={jahr === gruppen[0]} - > - {jahr.monate.map((monat) => ( + {gruppen.length === 0 ? ( + + + + ) : ( + + {gruppen.map((jahr) => ( f.distance_km ?? 0))} km`} - defaultOpen={monat === jahr.monate[0]} + key={jahr.jahr} + title={String(jahr.jahr)} + summary={ + <> + {de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km +
+ {jahr.eintraege.length} Fahrt{jahr.eintraege.length === 1 ? "" : "en"} + + } + defaultOpen={jahr === gruppen[0]} > - {monat.eintraege.map((fahrt) => ( - void api.fahrtLoeschen(fahrt.trip_id)} - kinder={ - - } - /> + {jahr.monate.map((monat) => ( + f.distance_km ?? 0))} km`} + defaultOpen={monat === jahr.monate[0]} + > + {monat.eintraege.map((fahrt, i) => ( + void api.fahrtLoeschen(fahrt.trip_id)} + kinder={ + geheZu("trip", fahrt.trip_id)} + letzte={i === monat.eintraege.length - 1} + /> + } + /> + ))} + ))}
))} -
- ))} +
+ )} + + {formularOffen && setzeFormularOffen(false)} />} + + setzeFormularOffen((offen) => !offen)} + > + {formularOffen ? "Abbrechen" : "Fahrt erfassen"} + ) } +/** Monatsname allein, wie im Panel (`toLocaleDateString({month:"long"})`). */ +function monatName(jahr: number, monat: number): string { + return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" }) +} + /** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. Dieselben Felder wie beim Bearbeiten einer bestehenden Fahrt (siehe FahrtFelderFormular in FahrtDetail.tsx) - beide teilen sich deshalb die @@ -149,7 +165,7 @@ function FahrtFormular({ beiFertig }: { beiFertig: () => void }) { return ( - Fahrt eintragen + Neue Fahrt void }) { {fehler}

)} -

- Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der - Fahrt kennt. -

+ {/* Ohne Erklärabsatz: das Panel führt im Neuanlage-Formular nur die + Felder und die Knöpfe. */}
- void speichern()} disabled={laeuft}> + void speichern()} disabled={laeuft}> {laeuft ? "Speichere …" : "Speichern"}
diff --git a/companion-app/src/screens/Fahrzeugdaten.tsx b/companion-app/src/screens/Fahrzeugdaten.tsx index bee5242..337bec9 100644 --- a/companion-app/src/screens/Fahrzeugdaten.tsx +++ b/companion-app/src/screens/Fahrzeugdaten.tsx @@ -1,13 +1,20 @@ /** * Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung. - * Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt - * man im Profil, nicht hier. + * Vorlage: `vIdent()`/`vIdentBearbeiten()` im alten Panel. + * + * „Daten bearbeiten" öffnet dieselbe Bearbeitungsansicht wie dort: die Werte + * des Datenblatts und die Ausstattungslisten, jeweils mit „×" zum Entfernen + * und „+ Position hinzufügen". Die Kopfdaten (Modell, Kennzeichen, Ausführung, + * Erstzulassung, FIN) bleiben bewusst außen vor — die pflegt „Fahrzeug + * einrichten" in den Einstellungen. */ -import { Accordion, Tile } from "@audi-dash/ui" +import { useState } from "react" + +import { ActionButton, Feld, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" -import { datum, de } from "../format" +import { datum } from "../format" import { Wertzeile, Werteliste } from "./bausteine" interface TechnikGruppe { @@ -23,6 +30,7 @@ interface AusstattungGruppe { export function Fahrzeugdaten() { const { fahrzeug, einstellungen } = useDaten() + const [bearbeiten, setzeBearbeiten] = useState(false) if (!fahrzeug || !einstellungen) return null const technik = Array.isArray(fahrzeug.technik) @@ -32,72 +40,257 @@ export function Fahrzeugdaten() { ? (fahrzeug.ausstattung as AusstattungGruppe[]) : [] + if (bearbeiten) { + return ( + setzeBearbeiten(false)} + /> + ) + } + + /* Aufbau 1:1 wie vIdent(): eine Kachel je Technik- und je + Ausstattungsgruppe, alle Angaben unmittelbar sichtbar. Bis 2026-08-30 + steckten sie hier in zugeklappten Aufklapp-Elementen — auf dem Bildschirm + blieben von über 3 000 Zeichen Datenblatt nur die Gruppennamen übrig. + Auch die Kopfkachel wich ab: sie hieß „Identität" statt „Fahrzeug", zeigte + „Hauptuntersuchung fällig" und „Kilometerstand", die das Panel hier nicht + führt, und ließ „Motor und Leistung" sowie „Zulassung" weg. */ return ( <> - Identität + Fahrzeug - - - - + + + + - - + + } /> - {technik.length > 0 && ( - - Technische Daten - {technik.map((gruppe, i) => ( - - ( - - ))} + {technik.map((gruppe, i) => ( + + {gruppe.gruppe ?? "Weitere Angaben"} + ( + noch offen} /> - - ))} + ))} + /> - )} + ))} - {ausstattung.length > 0 && ( - - Ausstattung - {ausstattung.map((gruppe, i) => ( - -
    - {(gruppe.sonder ?? []).map((posten) => ( -
  • {posten}
  • - ))} - {(gruppe.serie ?? []).map((posten) => ( -
  • - {posten} -
  • - ))} -
-
- ))} + {ausstattung.map((gruppe, i) => ( + + {gruppe.gruppe ?? "Weitere Ausstattung"} + ( + + ))} + /> - )} + ))} + + setzeBearbeiten(true)}>Daten bearbeiten + + ) +} + +/** Bearbeitungsansicht, Zuschnitt wie `vIdentBearbeiten()` im Panel. */ +function DatenBearbeiten({ + technik, + ausstattung, + beiFertig, +}: { + technik: TechnikGruppe[] + ausstattung: AusstattungGruppe[] + beiFertig: () => void +}) { + const { fahrzeug, profilSpeichern } = useDaten() + const [entwurfTechnik, setzeEntwurfTechnik] = useState(() => + technik.map((g) => ({ ...g, posten: (g.posten ?? []).map((p) => [p[0], p[1]] as [string, string]) })), + ) + const [entwurfAusstattung, setzeEntwurfAusstattung] = useState(() => + ausstattung.map((g) => ({ ...g, sonder: [...(g.sonder ?? [])] })), + ) + const [laeuft, setzeLaeuft] = useState(false) + + if (!fahrzeug) return null + + /* Das Panel speichert jedes Feld sofort; hier folgt der Bildschirm dem + Muster der übrigen Formulare dieser App — Entwurf plus einem Knopf, der + schreibt und zurückführt (Paritätsregel: gleiches Verhalten, eigenes + Idiom). Bei rund 50 Feldern spart das ebenso viele Schreibvorgänge auf + das gesamte Profil. */ + const speichern = async () => { + setzeLaeuft(true) + try { + await profilSpeichern({ + fahrzeug: { + ...fahrzeug, + technik: entwurfTechnik as unknown as typeof fahrzeug.technik, + ausstattung: entwurfAusstattung as unknown as typeof fahrzeug.ausstattung, + }, + }) + beiFertig() + } finally { + setzeLaeuft(false) + } + } + + return ( + <> + + Daten bearbeiten + + Hier stehen das technische Datenblatt und die Ausstattung. Modell, Kennzeichen, + Ausführung, Erstzulassung und Fahrgestellnummer pflegst du unter Einstellungen → Fahrzeug + einrichten. + + + + {entwurfTechnik.map((gruppe, gi) => ( + + {gruppe.gruppe ?? "Weitere Angaben"} + {(gruppe.posten ?? []).map((posten, i) => ( + + + { + const wert = e.target.value + setzeEntwurfTechnik((alt) => + alt.map((g, gj) => + gj !== gi + ? g + : { + ...g, + posten: (g.posten ?? []).map((p, pj) => + pj === i ? ([p[0], wert] as [string, string]) : p, + ), + }, + ), + ) + }} + /> + + + + ))} + {(gruppe.posten?.length ?? 0) === 0 && ( + + Keine Angaben + + )} + + setzeEntwurfTechnik((alt) => + alt.map((g, gj) => + gj !== gi + ? g + : { ...g, posten: [...(g.posten ?? []), ["Neue Angabe", ""] as [string, string]] }, + ), + ) + } + > + + Position hinzufügen + + + ))} + + {entwurfAusstattung.map((gruppe, gi) => ( + + {gruppe.gruppe ?? "Weitere Ausstattung"} + {(gruppe.sonder ?? []).map((posten, i) => ( + + + { + const wert = e.target.value + setzeEntwurfAusstattung((alt) => + alt.map((g, gj) => + gj !== gi + ? g + : { ...g, sonder: (g.sonder ?? []).map((s, sj) => (sj === i ? wert : s)) }, + ), + ) + }} + /> + + + + ))} + {(gruppe.sonder?.length ?? 0) === 0 && ( + + Keine Ausstattung hinterlegt + + )} + + setzeEntwurfAusstattung((alt) => + alt.map((g, gj) => (gj !== gi ? g : { ...g, sonder: [...(g.sonder ?? []), ""] })), + ) + } + > + + Position hinzufügen + + + ))} + + void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Fertig"} + ) } diff --git a/companion-app/src/screens/LiveFahrt.tsx b/companion-app/src/screens/LiveFahrt.tsx index 57f576c..a58f435 100644 --- a/companion-app/src/screens/LiveFahrt.tsx +++ b/companion-app/src/screens/LiveFahrt.tsx @@ -69,7 +69,7 @@ export function LiveFahrt() { <> diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx index cb619a4..f54bf91 100644 --- a/companion-app/src/screens/MeinAudi.tsx +++ b/companion-app/src/screens/MeinAudi.tsx @@ -18,7 +18,7 @@ import { useState } from "react" import { Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" -import { naechsterService } from "../daten/service" +import { inspektionPrognose, oelwechselPrognose } from "../daten/service" import { datum, de, deOderStrich, eur } from "../format" import type { SeitenName } from "../navigation" import { Wertzeile, Werteliste } from "./bausteine" @@ -34,7 +34,7 @@ interface AusstattungGruppe { } export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { - const { fahrzeug, einstellungen, fahrten } = useDaten() + const { fahrzeug, einstellungen } = useDaten() const [bildIndex, setzeBildIndex] = useState(0) if (!fahrzeug || !einstellungen) return null @@ -48,13 +48,46 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) = 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) + + /* Alle drei Termine, wie vAudi() im Panel. Bis 2026-08-30 stand hier nur + der zeitlich nächste (die Vereinfachung aus Kapitel 2 des ersten Audits) — + Inspektion und Hauptuntersuchung fehlten damit ganz. + + Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"), + nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose + steht klein unter dem Namen (prognoseZeile() dort). */ + const oelProg = oelwechselPrognose(fahrzeug, buch) + const inspProg = inspektionPrognose(fahrzeug, buch) + const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [ + { + art: "Ölwechsel", + prognose: oelProg + ? `${de(oelProg.restKm)} km, voraussichtlich am ${datum(oelProg.datum)}` + : null, + haupt: fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "–", + neben: + fahrzeug.oelwechselFaelligKm != null + ? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km` + : null, + }, + { + art: "Inspektion", + prognose: inspProg ? `voraussichtlich am ${datum(inspProg.datum)}` : null, + haupt: fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "–", + neben: + fahrzeug.inspektionFaelligKm != null + ? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km` + : null, + }, + { + // Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das + // im Profil hinterlegte Datum. + art: "Hauptuntersuchung", + prognose: null, + haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "–", + neben: null, + }, + ] const schild = schildErkennen(einstellungen.fahrzeugtitel) const ausfuehrung = einstellungen.ausfuehrung.trim() @@ -76,7 +109,7 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) = onClick={() => setzeBildIndex((i) => (i + 1) % BILDPLAETZE.length)} aria-label="Nächstes Bild" > - +