Merge branch 'umsetzung-datametric360': Companion-App-Phasen 1-10 uebernehmen

Bringt die vollstaendig gebaute DataMetric360-App zusammen (React/Vite,
21 Screens, Audi-Assets, PWA + native Capacitor-Huelle, 90+9 Tests) sowie
die profil_lesen()-Haertung gegen fehlende/kaputte Profildatei zusammen.

Konfliktaufloesung:
- AGENTS.md, INSTALL.md, README.md: main-Fassung war jeweils die
  chronologisch neuere, uebernommen und um die durch den Merge tatsaechlich
  erledigten Punkte ergaenzt (profil_lesen()-Haertung, Audit-Reste-Entscheidung).
- fahrterkennung.py: toten WLAN-Zweig vom Branch verworfen, Zuendungs-
  basierte Erkennung von main behalten.
- homeassistant/FMM003_MAPPING.md (MQTT/Mosquitto-Ansatz vom 2026-08-11,
  vor der Umstellung auf flespi) bewusst nicht uebernommen - main nutzt seit
  2026-08-12 flespi als alleinigen FMM003-Datenweg. UMSETZUNGSPLAN.md Phase 13
  entsprechend als ueberholt markiert, verweist auf AGENTS.md als massgeblich.
- REVIEW_main_2026-08-13.md, ha_install.md (add/add): main-Fassung war die
  spaetere Revision derselben Dokumente, uebernommen.

Die von REVIEW_main_2026-08-13.md befuerchtete Merge-Falle (profil_lesen()
gibt jetzt None zurueck, main-seitige Aufrufer pruefen das nicht) wurde
verifiziert als bereits entschaerft: alle 5 Aufrufstellen im gemergten Stand
(backup.py x2, fahrterkennung.py, frontend_veroeffentlichung.py,
reifenzaehler.py, tankerkennung.py) sind None-sicher.

installationspaket/ nicht Teil dieses Commits (gitignored, wird bei Bedarf
neu zusammengestellt).
This commit is contained in:
2026-08-13 19:03:20 +02:00
113 changed files with 17638 additions and 159 deletions
+128 -35
View File
@@ -1,6 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-13** (design/CI review added, see `DESIGN_REVIEW_2026-08-13.md`). This file is the entry point for every new agent
**Last updated: 2026-08-13** (merged the `umsetzung-datametric360` branch — companion app phases
110 done, see `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.
@@ -73,19 +74,22 @@ assets. Never mix these the other way around.
**Reading order for a new session:**
1. This file (overview + open items)
2. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
3. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, barely built)
4. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
5. `bauauftrag.md`**historical only**; code has diverged (see SPECIFICATION.md §7)
2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with
commands and acceptance criteria); when working on an open item, follow the plan's phase
3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 110 built)
5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
6. `bauauftrag.md`**historical only**; code has diverged (see SPECIFICATION.md §7)
## The three projects in this repo
| Area | What | Status |
|---|---|---|
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** |
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app |
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
| `companion-app/` | **DataMetric360** — successor app (Capacitor iOS/Android + HA iframe); will **replace** the panel | 🚧 data layer only (~930 lines TS), **no UI** |
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim (wrong model, main screens only) |
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | **all 21 screens built and tested**; runs natively on iOS with real data |
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
`bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the
@@ -103,12 +107,12 @@ Fahrzeug einrichten → Setup), stored as overrides in `data/entitaeten.json`;
`pyscript/modules/einstellungen.py` holds only the built-in defaults. Deploy: `update.ps1`
(robocopy to Samba share) or — still inactive — self-update from git.
**`companion-app/` — what exists:** dependency-free, strictly typed TS data layer (`src/api/`):
REST client (`rest.ts`), WebSocket client with auth flow + reconnect backoff (`live.ts`),
persistent offline write queue (`warteschlange.ts`, strict FIFO), environment detection
capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), facade
`DataMetricApi` (`index.ts`). Smoke test ran 7/7 green against Docker HA `audi_ha_test`
(localhost:18123) — unauthenticated only; token-authenticated reads/writes untested.
**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
reconnect backoff, persistent offline write queue, credential storage), domain logic
(`src/daten/`: profile adapter, statistics, service forecast, data context), all 21 screens
(`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and icons. Verified by 90 unit
and render tests plus 9 checks against a live Home Assistant. `npm run dev` in the repo root
starts it; `testumgebung/aufsetzen.sh` provides the server side.
**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
@@ -170,7 +174,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
frontend: no tests, no CI.
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.
### Documentation drift (small fixes; align docs to code)
### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong)
- `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment
`audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**;
@@ -179,19 +183,21 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).
### Audit leftovers (`AUDIT_2026-08-10.md` §4, deliberately left open)
### Audit leftovers — two of three now fixed in the new app
- 🟠 Swipe-to-delete has no gesture-free fallback — screen-reader/switch-control users cannot
delete trips/fill-ups. Needs a design decision (long-press vs. "…" button vs. action sheet).
- 🟡 Popup close-by-tap-outside is not keyboard-reachable (needs a quick manual check).
- 🟡 Self-host Leaflet JS/CSS (tiles necessarily stay remote).
- Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also
carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in
the old panel, which is being replaced anyway.
- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk.
- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's
Popup, which is keyboard-reachable).
- Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel
gets replaced anyway.
### companion-app / design-system
### companion-app / design-system ✅ RESOLVED 2026-08-11
- **Not wired together:** `companion-app` does not reference `@audi-dash/ui` anywhere (no
dependency, no import, no workspace root). The link exists only in prose.
- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the
app as a real dependency.
- Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first
(design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`).
- No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system).
@@ -201,24 +207,100 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
---
### Found while building (2026-08-11) — all fixed
Three defects that only surfaced by running against a real Home Assistant, not by reading code:
1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had
`tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes
`tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without
anything failing. `technik`/`ausstattung` were typed as objects but are arrays.
2. **The profile adapter handed out live references into the raw profile.** Editing a form would
have silently mutated the baseline and broken the promise never to overwrite the
backend-maintained tire odometer. Sections are copied now.
3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG
AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a
already-solved contrast defect.
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
what the smoke scripts run on — rewritten as plain fields.
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
the block to zero height and took model name, badge and plate with it. Fixed with
`.dm-inhalt > * { flex: none }`.
5. **The model name appeared twice** — once as the badge image, once as text beside it.
6. **Filenames overflowed the gallery thumbnails.**
And one in the backend, found by watching the log against a current Home Assistant: the odometer
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
(no network call, token never leaves the local network) and round-trips correctly.
## Pending: this branch has diverged from `main` (noted 2026-08-13)
`ha_install.md` (root) plans the move from pyscript to a **native HA integration** with a config
flow — UI-only setup, no YAML, and updates via a self-reporting `UpdateEntity` against the Gitea
repo (HACS is GitHub-only, so it is not an option). All APIs in it were verified against the
running 2026.8.1 instance. It also records which review findings that move eliminates by design.
A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
which makes "securely parked" report safe while three doors were never checked).
`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
owner keeps working on `main` first.
Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.
**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
would raise `AttributeError` on a missing profile instead of logging a clear error.
When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
every remaining `profil.profil_lesen()` call site.
## Open items
Execution order, exact steps, and acceptance criteria for every item below live in
`UMSETZUNGSPLAN.md` (phases 113). Additional decisions of 2026-08-11: **no Electron** (Capacitor
wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and
**no separate backend** (the app talks to the HA REST/WebSocket API directly).
### A) Build DataMetric360 (the big block)
- [ ] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
then re-export to `design/`
- [ ] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
dependency (possibly add a workspace/monorepo root)
- [ ] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
- [ ] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/`
- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/`
**never** into `design-system/`
- [ ] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
`ablageSetzen()` hook already exists)
- [ ] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
locally under HA `/local/`, architecture §3)
- [ ] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
against `audi_ha_test` with a real token
- [ ] Offline UX per design brief (offline marker, visible pending queue)
- [x] Offline UX per design brief (offline marker, visible pending queue)
### B) Infrastructure / commissioning (partly waits for FMM003 hardware)
@@ -227,7 +309,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
- [ ] Decide hostname split (app on apex + API on `api.` subdomain, or vice versa)
- [ ] Choose reverse proxy (Nginx Proxy Manager vs. Traefik) — **can be done before hardware**
against the existing HA API
- [ ] Define the reverse-proxy path allowlist (depends on final entity/service names)
- [x] Define the reverse-proxy path allowlist (depends on final entity/service names)
- [ ] Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent)
- [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`).
@@ -420,9 +502,20 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
was left unexercised beyond confirming it registers without error at pyscript load time.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
2026-08-12 (see section B)
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
- [ ] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`,
`homeassistant/README.md`, the profile template)
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the
`umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns
`None` on a missing or malformed file instead of raising; all 5 call sites
(`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`,
`tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no
caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md`
§"Was den Branch betrifft").
- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard
access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in
DataMetric360.
- [ ] Optional: persist the RAM-only states (trip start, fuel low-water-mark) — deliberately
deferred; may become moot with the FMM003 switch
- [ ] Upload vehicle photos to `www/bilder/`, set `steuer.faellig` (operational data, not code)
+600
View File
@@ -0,0 +1,600 @@
# DataMetric360 — Umsetzungsplan
**Stand: 2026-08-11.** Dieser Plan führt von heute (fertiges HA-Panel, fertige Datenschicht,
halbfertiger Design-Entwurf) bis zur fertigen DataMetric360-App inklusive aller offenen Punkte aus
`AGENTS.md`. Er ist bewusst kleinteilig geschrieben, damit jede Phase auch von einem schwächeren
KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann.
**Beschlossene Rahmenbedingungen (nicht neu diskutieren):**
- Alle Daten liegen in Home Assistant. Es gibt **kein eigenes Backend** — die App spricht direkt
mit der HA-REST-/WebSocket-API und ruft die vorhandenen `pyscript.audi_dashboard_*`-Services auf.
- **Kein Electron.** Fürs iPhone wird die Web-App mit **Capacitor** verpackt (nur Sideload, nie
App Store). Optionaler Zwischenschritt: als PWA auf den Homescreen (Phase 10).
- Die App ersetzt später das HA-Panel; bis dahin bleibt das Panel unangetastet in Betrieb.
---
## Für das ausführende Modell: Arbeitsregeln
1. **Lies zuerst:** `AGENTS.md` (Regeln + Stand), dann die Phase, die du bearbeitest — nichts
anderes. Hole dir Detailwissen erst, wenn ein Schritt es verlangt (die Schritte nennen die
Quelldatei).
2. **Eine Phase pro Session.** Arbeite die Schritte in Reihenfolge ab. Jeder Schritt hat eine
Prüfung („✅ Fertig wenn"). Erfülle sie, bevor du weitergehst. Wenn eine Prüfung fehlschlägt,
behebe das, bevor du den nächsten Schritt beginnst.
3. **Nach jeder abgeschlossenen Phase:** Haken in diesem Plan setzen, betroffene Checkboxen in
`AGENTS.md` abhaken, „Last updated" in `AGENTS.md` aktualisieren (englisch!), committen mit
deutscher Commit-Message.
4. **Harte Verbote:**
- Audi-Assets (Schriften, Ringe, Typenschilder) niemals nach `design-system/` kopieren oder in
irgendetwas einbauen, das veröffentlicht/hochgeladen wird. Quelle für die App:
`homeassistant/www/` und `design/uploads/`.
- Keine Schriftgewichte ≥ 600, keine Schatten, keine Verläufe. Nur Gewichte 300/400.
- `homeassistant/` nicht umbauen (nur die explizit genannten Pflege-Schritte in Phase 2).
- Keine zusätzlichen npm-Pakete einführen, die der Plan nicht nennt, ohne es zu begründen und
in `AGENTS.md` zu dokumentieren.
- Home Assistant niemals direkt ins Internet stellen. Nur die in Phase 12 beschriebenen Wege.
5. **Sprache:** Code-Bezeichner, Kommentare, UI-Texte, Commits auf Deutsch (Ausnahme: `AGENTS.md`
englisch; `design-system/` behält englische Props).
6. **Zahlenformat** immer `de-DE` (1.234,5). Übernimm die Helfer `de()`/`eur()` aus dem alten
Panel (`homeassistant/www/audi-dashboard-app.js`), statt eigene zu erfinden.
## Phasenübersicht und Abhängigkeiten
| Phase | Inhalt | Hängt ab von | Braucht Hardware/Extern? |
|---|---|---|---|
| 1 | Arbeitsumgebung herstellen und verifizieren | — | nein |
| 2 | Pflege HA-Panel (Doku-Drift, Härtung) | — | nein |
| 3 | Design-Entwurf vervollständigen | — | Claude Design (Besitzer) |
| 4 | Workspace + React-Gerüst in `companion-app/` | 1 | nein |
| 5 | App-Shell: Theme, Router, responsives Layout | 4 | nein |
| 6 | Onboarding + Datenanbindung + Offline-UX | 5 | nein |
| 7 | Alle Screens umsetzen | 3, 6 | nein |
| 8 | Audi-Assets einbauen | 7 | nein |
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 710 | HA-Produktivinstanz |
| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
Phasen 2, 3 und 12 (Teilschritte) sind unabhängig und können vorgezogen werden. Phase 13 ist die
einzige, die zwingend auf Hardware wartet.
---
## Phase 1 — Arbeitsumgebung herstellen und verifizieren ✅ ERLEDIGT (2026-08-11)
> Ergebnis: design-system baut und besteht 21/21, companion-app typecheckt und besteht 7/7.
> Die verlorene Testinstanz wurde als reproduzierbares Skript neu aufgebaut — siehe
> `testumgebung/` (Abweichung vom Plan, bewusst: das Original ging genau deshalb verloren,
> weil es nur als Anleitung existierte).
**Ziel:** Beide npm-Pakete bauen, beide Smoke-Tests laufen, die HA-Testinstanz ist erreichbar.
1. Node-Version prüfen: `node --version` — muss ≥ 20 sein (companion-app nutzt
`--experimental-strip-types`, ab Node 22 stabil; wenn < 20: mit nvm/Volta Node 22 installieren).
2. `cd design-system && npm install && npm run build`
✅ Fertig wenn: `design-system/dist/index.js` und `dist/styles.css` existieren, Build ohne Fehler.
3. `npm run smoke` (im selben Ordner)
✅ Fertig wenn: 21/21 Fälle „ok" melden.
4. `cd ../companion-app && npm install && npm run typecheck`
✅ Fertig wenn: `tsc --noEmit` fehlerfrei durchläuft.
5. HA-Testinstanz prüfen: `curl -s -o /dev/null -w '%{http_code}' http://localhost:18123/api/`
erwartet: `401` (API da, Token fehlt — das ist richtig so). Wenn keine Antwort: Der
Docker-Container heißt `audi_ha_test` (`docker start audi_ha_test`). Existiert er nicht, siehe
`homeassistant/README.md` (Testbericht-Abschnitt) für den Aufbau; notfalls Phase-1-Schritt 6
überspringen und in Phase 9 nachholen.
6. `npm run smoke` in `companion-app/`
✅ Fertig wenn: 7/7 Prüfungen grün.
**Abschluss Phase 1:** nichts committen (nur `node_modules`/`dist`, beides gitignored).
---
## Phase 2 — Pflege des bestehenden HA-Panels ✅ ERLEDIGT (2026-08-11)
> Doku-Drift behoben, `profil_lesen()` gehärtet und an der Testinstanz belegt.
> Offen bleibt nur, was Betriebsdaten sind: Fahrzeugfotos und `steuer.faellig`.
**Ziel:** Doku stimmt wieder mit dem Code überein; ein Crash-Risiko ist beseitigt. Reine Pflege —
**keine** Funktionsänderungen.
1. **Statistik-Behauptung korrigieren** (3 Stellen, gleiche Falschaussage „zeigt Beispielzahlen"):
- `homeassistant/www/audi-dashboard-app.js` Kopfkommentar Zeilen ~1315
- `homeassistant/README.md` Zeile ~99
- `homeassistant/INSTALL.md` Zeile ~204
Neue Aussage sinngemäß: „Die Statistik-Seite berechnet echte Werte aus Fahrten und
Tankvorgängen (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)."
2. **INSTALL.md Schritt 4** (Zeilen ~106109): falsche Variablennamen ersetzen.
Falsch: `DOORS_SENSOR`, `WINDOWS_SENSOR`, `LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`.
Richtig (siehe `homeassistant/pyscript/modules/einstellungen.py`): `TUER_SENSOREN`,
`FENSTER_SENSOREN`, `TUERSCHLOSS_SENSOREN` (je 4er-Listen) und `BATTERIE_SENSOR` (einzeln).
3. **README.md:** Erwähnung der „97-%-Volltankungsregel" (~Zeile 110) streichen (Regel wurde
entfernt, siehe Kommentar `pyscript/belegverarbeitung.py:18-20`); in der Dateiübersicht die
fehlenden Skripte ergänzen: `tankerkennung.py`, `batterieverlauf.py`, `bilderverwaltung.py`,
`backup.py`, `updateverwaltung.py`.
4. **Obsoleten Kommentar entfernen:** `pyscript/belegverarbeitung.py:41` — den Zusatz
„TODO: Datei ablegen" streichen (Datei existiert längst), Konstante selbst unverändert lassen.
5. **`profil_lesen()` härten** (`pyscript/modules/profil.py`, ~Zeile 5155): fehlende oder
nicht parsebare `fahrzeugprofil.json` abfangen. Verhalten: Fehler ins Log (`log.error` ist in
pyscript global verfügbar), Rückgabe `None`; Aufrufer prüfen. Vorher alle Aufrufer suchen
(`grep -rn "profil_lesen" homeassistant/pyscript/`) und sicherstellen, dass jeder mit `None`
umgehen kann — wo nicht, dort eine frühe Rückkehr einbauen. **pyscript-Eigenheit beachten:**
Datei-I/O nur über `task.executor(io.open, …)`-Muster wie im Bestand, kein nacktes `open()`.
✅ Fertig wenn: `python3 -c "import ast; ast.parse(open('homeassistant/pyscript/modules/profil.py').read())"`
fehlerfrei ist und jeder Aufrufer den `None`-Fall behandelt.
6. **Bewusst NICHT tun:** Swipe-Delete-Fallback, Popup-Tastaturzugang, Leaflet-Selbsthosting
(Audit-Reste) — Entscheidung laut Audit: kommt in DataMetric360, nicht ins alte Panel.
RAM-only-Zustände (Fahrtstart, Tank-Tiefststand) ebenfalls nicht anfassen — wird mit dem
FMM003-Umstieg hinfällig.
7. Committen: „Doku an Codestand angleichen und profil_lesen gegen fehlende Datei härten".
`AGENTS.md`: die erledigten Punkte in Block C abhaken.
Offen bleiben in Block C nur: Fahrzeugfotos hochladen + `steuer.faellig` setzen (macht der
Besitzer in der App/UI, kein Code).
---
## Phase 3 — Design-Entwurf vervollständigen ⏭️ ÜBERSPRUNGEN (2026-08-11)
> Entscheidung des Besitzers: Die Screens wurden direkt aus den Views des alten
> Panels abgeleitet, statt auf den Claude-Design-Entwurf zu warten. Dieser
> Abschnitt bleibt stehen, falls der Entwurf später auf den Stand der
> Umsetzung gebracht werden soll.
**Ziel:** Der Entwurf zeigt das richtige Fahrzeug und alle Seiten, die das alte Panel hat.
Diese Phase läuft im Claude-Design-Projekt
(https://claude.ai/design/p/c28a8d4d-ec4e-4178-9e49-ab5b90c02097) — der Besitzer stößt sie an;
ein Agent kann den Auftragstext vorbereiten und den Export danach einpflegen.
1. **Korrektur Modell:** überall „Audi RS 4 Avant competition" statt „RS 6 Avant"; Typenschild
`rs4` (negative/positive) statt `rs6`.
2. **Fehlende Seiten ergänzen** (16 Stück; Vorlage ist jeweils die View im alten Panel —
Funktionsnamen aus `homeassistant/www/audi-dashboard-app.js`, Beschreibung in
`SPECIFICATION.md` §3 „Views"):
| # | Seite | Vorlage (alte View) |
|---|---|---|
| 1 | Fahrzeugstatus-Detail (12-Punkte Türen/Fenster/Schlösser) | `vSicherheit()` |
| 2 | Fahrzeugdaten/Technik/Ausstattung | `vIdent()` |
| 3 | Batterieverlauf (SVG-Kurve, SoC/SoH) | `vBatterieverlauf()` |
| 4 | Service (Termine, Terminanfrage, Servicebuch-Liste) | `vService()` |
| 5 | Werkstatt bearbeiten | `vWerkstatt()` |
| 6 | Servicebuch-Eintrag (ansehen/bearbeiten/neu) | `vSbuch(i)` |
| 7 | Versicherung & Steuer (Hub) | `vVers()` |
| 8 | Beitrag bearbeiten | `vBeitrag()` |
| 9 | Vertragsdetails | `vVertragsdetails()` |
| 10 | Schutzbrief (nur lesen) | `vSchutz()` |
| 11 | Notrufnummern bearbeiten | `vNotrufBearbeiten()` |
| 12 | Kfz-Steuer (Ansicht + Bearbeiten) | `vSteuer()`/`vSteuerBearbeiten()` |
| 13 | Reifen (Sätze, km-Zähler, Wechsel, Drehmoment) | `vReifen()` |
| 14 | Fahrt-Detail (Karte, Daten, Bearbeiten) | `vTrip(id)` |
| 15 | Tankvorgang-Detail (inkl. Beleg-Upload/-Ersetzen) | `vFill(id)` |
| 16 | Einstellungen in voller Tiefe (Fahrzeug-Setup, Fotos, SmartDeal, Pausenzeit, Export/Import, Backup, Version/Update) | `vEinst()` |
Für jede Seite beide Layoutvarianten (schmal mit Tab-Leiste unten, breit ≥1000px mit
Seitenleiste) — wie im Brief `DESIGN_BRIEF_DATAMETRIC360.md` gefordert.
3. **Export einpflegen:** kompletten Ordner `design/` durch den neuen Export ersetzen
(`design/README.md` sagt selbst: „wird beim nächsten Export komplett ersetzt"), README-Abschnitt
„Stand dieses Exports" aktualisieren.
✅ Fertig wenn: `grep -ci "rs 6\|rs6" design/DM360.dc.html` = 0 und alle 16 Seiten im Export
auffindbar sind.
4. Committen; `AGENTS.md` Block A Punkt 1 abhaken.
**Wichtig:** Phase 7 kann pro Screen schon vor Abschluss dieser Phase beginnen — die 8 vorhandenen
Hauptscreens sind entworfen; die 16 Unterseiten folgen dann dem gleichen Muster.
---
## Phase 4 — Workspace und React-Gerüst ✅ ERLEDIGT (2026-08-11)
**Ziel:** `companion-app/` ist ein lauffähiges Vite+React+TS-Projekt, das `@audi-dash/ui` als
echte Dependency nutzt. Die bestehende Datenschicht (`src/api/`) bleibt unverändert liegen.
1. **npm-Workspace-Root anlegen** — neue Datei `package.json` im Repo-Root:
```json
{
"name": "audi-app",
"private": true,
"workspaces": ["design-system", "companion-app"]
}
```
Dazu Repo-Root-`.gitignore` um `node_modules/` ergänzen (falls nicht abgedeckt).
2. **Dependencies in `companion-app/package.json` ergänzen** (bestehende Felder beibehalten!):
`dependencies`: `react ^18`, `react-dom ^18`, `@audi-dash/ui` (Versionsangabe `*` — kommt über
den Workspace); `devDependencies` zusätzlich: `vite`, `@vitejs/plugin-react`,
`@types/react`, `@types/react-dom`. Scripts ergänzen: `"dev": "vite"`,
`"build": "vite build"`, `"preview": "vite preview"`.
3. `npm install` **im Repo-Root** ausführen (verlinkt design-system in die companion-app).
Danach einmal `npm run build --workspace design-system` (die App importiert aus `dist/`).
4. **Vite-Konfiguration** `companion-app/vite.config.ts`:
```ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
server: { port: 5173 },
build: { outDir: "dist", target: "es2022" },
})
```
5. **tsconfig anpassen:** Das bestehende `companion-app/tsconfig.json` ist auf die reine
Datenschicht zugeschnitten (`noEmit`, sehr streng — so lassen). Ergänzen: `"jsx": "react-jsx"`,
`"lib": ["ES2022", "DOM", "DOM.Iterable"]` und `"moduleResolution": "bundler"`, falls nicht
gesetzt. Die strengen Flags (`exactOptionalPropertyTypes` usw.) NICHT abschwächen.
6. **Einstieg anlegen:**
- `companion-app/index.html` — Minimal-HTML mit `<div id="wurzel"></div>` und
`<script type="module" src="/src/main.tsx"></script>`; `<html lang="de">`;
`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`.
- `companion-app/src/main.tsx`:
```tsx
import { createRoot } from "react-dom/client"
import "@audi-dash/ui/styles.css"
import { App } from "./App"
createRoot(document.getElementById("wurzel")!).render(<App />)
```
- `companion-app/src/App.tsx` — vorerst nur:
```tsx
export function App() {
return <div className="ads-root" data-theme="nacht" style={{ minHeight: "100vh" }}>DataMetric360</div>
}
```
**Pflicht:** Das äußerste Element trägt immer `className="ads-root"` und `data-theme` — ohne
diese Wurzelklasse greifen die Design-Tokens nicht (Regel aus
`design-system/.design-sync/conventions.md`).
7. ✅ Fertig wenn: `npm run dev --workspace companion-app` startet, http://localhost:5173 zeigt
dunklen Hintergrund (`#161b23`) mit Text; `npm run typecheck --workspace companion-app` und
`npm run build --workspace companion-app` laufen fehlerfrei.
8. Committen: „companion-app: Vite+React-Gerüst und Workspace-Anbindung an @audi-dash/ui".
`AGENTS.md` Block A Punkt 2 abhaken, Statustabelle aktualisieren.
---
## Phase 5 — App-Shell: Theme, Navigation, responsives Layout ✅ ERLEDIGT (2026-08-11)
**Ziel:** Navigationsgerüst, das beide Layouts beherrscht (schmal: Tab-Leiste unten; breit ≥1000px:
Seitenleiste), mit Tag/Nacht-Theme und Zurück-Navigation. Noch ohne echte Daten.
**Vorbild für Verhalten:** das alte Panel (`audi-dashboard-app.js`) — flacher Routenzustand,
`ZURUECK`-Tabelle. Vorbild fürs Aussehen: `design/DM360.dc.html` (dort existiert der
`wide`-Umschalter mit 240px-Seitenleiste bereits).
1. **Kein Router-Paket installieren.** Eigener flacher Zustand wie im alten Panel:
`src/navigation.ts` mit `type Route = { name: RouteName; id?: string }`, React-State im
App-Root, `geheZu(name, id?)`, plus `ZURUECK`-Tabelle (aus dem alten Panel übernehmen und um
die neuen Routen ergänzen; 3-stufige Verschachtelung `schutz → vertragsdetails → vers` beachten).
2. **Theme:** `src/theme.ts` — Zustand `"nacht" | "tag"`, Persistenz in `localStorage`
(Schlüssel `dm360.theme`), Initialwert: gespeicherter Wert, sonst `prefers-color-scheme`,
Standard `nacht`. Gesetzt wird ausschließlich `data-theme` auf dem `ads-root`-Element.
`localStorage`-Zugriffe in try/catch (Muster aus dem alten Panel, Private-Mode-Browser).
3. **Layout:** `src/Shell.tsx` — CSS-Grid/Flex mit Breakpoint bei 1000px
(`window.matchMedia("(min-width: 1000px)")` + Listener):
- schmal: Inhalt + `TabBar`-Komponente (aus `@audi-dash/ui`) unten, `env(safe-area-inset-*)`
als Padding (iPhone-Notch).
- breit: 240px-Seitenleiste links (Navigationspunkte + Ringe-Platzhalter oben), Inhalt rechts;
Listen-Detail nebeneinander wo der Entwurf es vorsieht.
Die 5 Hauptbereiche: Übersicht, Mein Audi, Fahrten, Statistik, Tanken. Einstellungen über
Zahnrad oben rechts (nicht in der Tab-Leiste) — wie im alten Panel und im Entwurf.
4. **Platzhalter-Screens:** je Hauptbereich eine Datei unter `src/screens/` (z. B.
`Uebersicht.tsx`), die vorerst nur Titel per `ads-eyebrow`/`Tile` zeigt.
5. ✅ Fertig wenn: Im Browser (a) bei < 1000px die Tab-Leiste unten erscheint und alle 5 Bereiche
wechselbar sind, (b) bei ≥ 1000px stattdessen die Seitenleiste erscheint, (c) der Theme-Wechsel
sichtbar Tag/Nacht umschaltet und einen Reload überlebt.
6. Committen: „companion-app: App-Shell mit responsivem Layout, Navigation und Theme".
---
## Phase 6 — Onboarding, Datenanbindung, Offline-UX ✅ ERLEDIGT (2026-08-11)
**Ziel:** Die App verbindet sich echt mit Home Assistant über die vorhandene Datenschicht.
**Die Datenschicht ist fertig — nur benutzen, nicht neu bauen.** Einstieg:
`companion-app/src/api/index.ts` (`DataMetricApi`). Sie liefert: REST-Reads
(`profilLesen`, `fahrtenLesen`, `tankvorgaengeLesen`, `fahrzeugstatusLesen`), Live-Updates
(`HassLive` mit Reconnect), Schreibzugriffe über die Offline-Queue (`profilSchreiben`,
`belegHochladen`, `fahrtLoeschen`, `tankvorgangLoeschen`, `jetztAktualisieren`) und
Zugangsdaten-Verwaltung (`umgebung.ts`).
1. **Ersteinrichtungs-Screen** (`src/screens/Einrichtung.tsx`) nach dem Entwurf in
`design/DM360.dc.html` (dort der `isSetup`-Block): Felder Server-Adresse + Zugangs-Token,
Knopf „Verbinden" ruft `verbindungPruefen()` aus der Datenschicht; Fehler unterscheiden nach
`ApiFehler.istAnmeldeproblem` (Token falsch) vs. `istNetzproblem` (Adresse/Netz) und in
verständlichem Deutsch anzeigen. QR-Knopf: vorerst ausblenden oder deaktiviert mit Hinweis
(kommt mit Capacitor, Phase 10). Bei Erfolg Zugangsdaten über die `Ablage` der Datenschicht
speichern und in die App wechseln.
2. **Datenkontext:** `src/datenkontext.tsx` — ein React-Context, der beim Start `DataMetricApi`
instanziiert, initial alle vier Reads lädt, sich bei `aufZustand()` für Live-Updates
registriert und `{profil, fahrten, tankvorgaenge, status, verbindung}` bereitstellt.
**Übernahme-Pflicht aus dem alten Panel:** die Umrechnungen `profilZuConfig()`/`profilZuCar()`
(inkl. SmartDeal-Gültigkeit: aktiv wenn `laeuft_ab` fehlt oder ≥ heute, Ablaufdatum
**einschließlich**) aus `audi-dashboard-app.js` nach TypeScript portieren — Logik exakt
beibehalten.
3. **Offline-UX** (Entwurf: Offline-Marker in der Kopfzeile):
- Verbindungszustand aus `aufVerbindung()` der Datenschicht → dezenter „Offline"-Hinweis im
Kopf, Daten bleiben sichtbar.
- Warteschlange aus `aufAenderung()` → sichtbare Zahl wartender Änderungen („2 Änderungen
warten auf Übertragung"), wie im Design-Brief gefordert.
4. **Stale-Anzeige:** `last_updated` des Fahrzeugstatus mitführen (Muster: Staleness-Indikator des
alten Panels).
5. ✅ Fertig wenn: Gegen `audi_ha_test` (Token siehe Phase 9 Schritt 1): Einrichtung mit falschem
Token zeigt Anmeldefehler, mit richtigem Token lädt die App Profil + Status; HA-Container
stoppen → Offline-Marker erscheint; Container starten → verschwindet wieder (Reconnect testet
die Backoff-Logik der Datenschicht).
6. Committen; `AGENTS.md` Block A Punkte Onboarding/Offline teilweise abhaken (QR bleibt offen).
---
## Phase 7 — Alle Screens umsetzen ✅ ERLEDIGT (2026-08-11)
> Alle 21 Seiten stehen. Die Entwurfsvorlage kam für die 16 Unterseiten aus den
> Views des alten Panels statt aus Claude Design (Entscheidung des Besitzers,
> 2026-08-11) — Phase 3 ist damit gegenstandslos, solange der Entwurf nicht
> nachgezogen werden soll.
**Ziel:** Funktionsgleichheit mit dem alten Panel plus die neuen Live-Ansichten. Pro Screen:
Entwurf aus `design/` nachbauen (mit `@audi-dash/ui`-Komponenten), Logik aus der alten View
portieren, an den Datenkontext anschließen.
**Reihenfolge und Zuordnung** (Logik-Vorlage = Funktion in `audi-dashboard-app.js`;
Datenquelle = Feld im Datenkontext):
| Screen | Logik-Vorlage | Datenquelle / Services | Hinweise |
|---|---|---|---|
| Übersicht | `vHome()` | status, profil, fahrten, tankvorgaenge | Foto, Status „sicher abgestellt", Reichweite, km, nächster Service, Teaser letzte Fahrt/Tankung; Pull-to-Refresh → `jetztAktualisieren()` |
| Fahrten-Liste | `vTrips()` | fahrten | Jahr→Monat-Akkordeon (`Accordion`); Lösch-Aktion: **nicht nur Swipe** — zusätzlich „…"-Menü pro Zeile (behebt Audit-Finding barrierefreies Löschen); manuelles Anlegen (`vTripsFormular()`) über Service `audi_dashboard_fahrt_manuell_anlegen` |
| Fahrt-Detail | `vTrip(id)` | fahrten | Karte: Leaflet **lokal bündeln** (`npm i leaflet`, Audit-Finding CDN), Tile-Layer nach Theme; **kein `fakeTrack()` portieren** — ohne echte Route nur Start/Ende-Marker zeigen, mit ehrlichem Leerhinweis |
| Tanken-Liste | `vFuel()` | tankvorgaenge | volumengewichteter Durchschnittspreis; Lösch-„…"-Menü wie bei Fahrten |
| Tankvorgang-Detail | `vFill(id)` | tankvorgaenge | Beleg-Upload: Datei → Base64 → `belegHochladen()`; Ergebnis kommt asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis` (Muster im alten Panel: `belegErgebnisVerarbeiten()`) |
| Statistik | `vStat()` | fahrten, tankvorgaenge | Rechenfunktionen (`fahrtenSeit`, `tankSeit`, `verbrauch`, `istNachtZeit`) 1:1 portieren |
| Mein Audi (Hub) | `vAudi()` | profil | Fotogalerie + Kachel-Links auf die Unterseiten |
| Fahrzeugstatus-Detail | `vSicherheit()` | status | 16-Punkte-Liste; `ok === null` heißt „unbekannt", nie grün raten |
| Fahrzeugdaten | `vIdent()` | profil (technik, ausstattung) | statisch aus dem Profil |
| Batterieverlauf | `vBatterieverlauf()` | eigener Read der Entität `pyscript.audi_dashboard_batterieverlauf` | SVG-Chart portieren (Pinch/Pan/Tap); Leerzustand prominent — Sensor ist derzeit unbesetzt |
| Service + Werkstatt + Servicebuch | `vService()`, `vWerkstatt()`, `vSbuch()` | profil.service | Öl-/Service-Prognose (`oelwechselPrognose()`, `intervalle()`, `termine()`) exakt portieren; ICS-Export (`ics()`-Helfer) übernehmen |
| Versicherung/Steuer-Gruppe | `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`, `vNotrufBearbeiten()`, `vSteuer()` | profil.versicherung, profil.steuer | reine Formular-/Anzeige-Seiten; Schreiben immer als ganzes Profil über `profilSchreiben()` (es gibt keinen Feld-einzeln-Service!) |
| Reifen | `vReifen()` | profil.reifen + Entitäten `pyscript.reifen_*` | Wechsel über Service `audi_dashboard_reifen_wechseln` (nur `"sommer"`/`"winter"`); Wechseldatum → ICS |
| Einstellungen | `vEinst()` | profil, alle | volle Tiefe: Fahrzeug-Setup, Theme, Fotos (Upload über `audi_dashboard_bild_hochladen`, nur die 10 erlaubten Dateinamen!), SmartDeal, Pausenzeit, Profil-Export/Import, Backup, CSV-Export, Version |
| **Neu: Live-Fahrt** | — (neu) | status (künftig FMM003-Felder) | Entwurf `screen === 'live'`: Karte, Geschwindigkeit groß, Strecke/Dauer; bis Phase 13 hinter einem Feature-Schalter `LIVE_VERFUEGBAR = false` in einer zentralen Konfigurationsdatei — Screen bauen, Schalter erst mit echten Daten umlegen |
| Leerzustände | — | — | für Fahrten/Tanken/Statistik je einen freundlichen „noch keine Daten"-Zustand (Design-Brief), wird real gebraucht: beide JSONL sind aktuell leer |
**Regeln für alle Screens:**
- Nur `@audi-dash/ui`-Komponenten + Tokens (`var(--…)`), keine neuen Hex-Farben, keine neuen
Komponenten ohne sie zuerst in `design-system/` anzulegen (dann dort mit CSS+Preview, danach
hier verwenden).
- Destruktive Aktionen immer mit Bestätigung; Fehler sichtbar in rot inline (Muster altes Panel).
- Jede Zahl durch `de()`/`eur()`.
- Nach jedem Screen: `npm run typecheck` + Sichtprüfung schmal UND breit.
✅ Fertig wenn: jeder Screen der Tabelle existiert, mit Testdaten befüllbar ist und in beiden
Layouts bedienbar aussieht wie der Entwurf. Committen pro Screen-Gruppe (z. B. „Fahrten-Screens"),
nicht ein Riesencommit.
---
## Phase 8 — Audi-Assets einbauen ✅ ERLEDIGT (2026-08-11)
**Ziel:** Die App sieht aus wie das Original — Schriften, Ringe, Typenschilder.
1. Quellen: Schriften aus `design/uploads/*.ttf` (oder die woff2-Base64-Blöcke aus
`homeassistant/www/audi-dashboard.css`), Ringe `design/assets/audi-rings-{white,black}.svg`,
Typenschilder aus `homeassistant/www/badges/` (alle 14).
2. Ablage: `companion-app/src/assets/audi/` + `@font-face`-Deklarationen in einer eigenen
`src/audi-schrift.css` (Familien exakt wie im Bestand: `"Audi Type"` 400, `"Audi Type Wide"`
300/400, `"Audi Type Extended"` italic). Der `--font-stack` wird per CSS-Variable auf dem
`ads-root` überschrieben — `design-system/`-Dateien bleiben unberührt.
3. Typenschild-Auswahl nach Modell aus dem Profil (Logik aus `vEinst()`/Badge-Mapping des alten
Panels übernehmen; Theme-abhängig positive/negative-Variante).
4. **Kontrolle:** `git log --stat` des Commits zeigt KEINE Änderung unter `design-system/`;
Gewichte ≥ 600 kommen nirgends vor (`grep -rn "font-weight" companion-app/src | grep -vE "300|400"`
liefert nichts).
5. Committen; `AGENTS.md` Block A Assets-Punkt abhaken. Hinweis im Commit: Repo bleibt privat
(Lizenz).
---
## Phase 9 — Tests ✅ ERLEDIGT (2026-08-11)
> 90 Unit- und Rendertests, dazu 9 Prüfungen gegen die laufende Instanz.
**Ziel:** Die ungeteste Hälfte der Datenschicht ist abgedeckt; die Kern-Rechenlogik hat Unit-Tests.
1. **Token für die Testinstanz erzeugen:** `audi_ha_test` im Browser öffnen
(http://localhost:18123), anmelden, Profil → Sicherheit → langlebiges Zugriffstoken erstellen.
Token NUR in `companion-app/.env.local` ablegen (`VITE_TEST_TOKEN=…`), Datei in `.gitignore`
aufnehmen. **Niemals committen.**
2. **Authentifizierter Smoke** (`scripts/smoke-auth.ts`, Aufbau wie `scripts/smoke.ts`): mit Token
(a) `zustandLesen("pyscript.audi_dashboard_profil")` liefert Daten, (b) `dienstAufrufen` mit
einem harmlosen Service (`audi_dashboard_jetzt_aktualisieren`) gibt 200, (c) Queue-Round-Trip:
Eintrag einstellen bei gestopptem Container, Container starten, Queue leert sich. Script in
`package.json` als `smoke:auth`; ohne gesetztes Token bricht es mit klarer Meldung ab
(Exit-Code 0, „übersprungen").
3. **Unit-Tests** mit `vitest` (einzige neue Dev-Dependency): portierte Rechenlogik testen —
Statistik (`verbrauch`, `istNachtZeit`, Zeiträume), Service-Prognose (`oelwechselPrognose`:
Neustart nach Ölwechsel, 180-Tage-Gewichtung, Kappung), SmartDeal-Gültigkeit (Ablauftag
einschließlich!), `de()`/`eur()`-Formatierung. Testdaten als Fixtures aus realistischen
JSONL-Zeilen (Schema: `SPECIFICATION.md` §5).
4. ✅ Fertig wenn: `npm run smoke:auth` 3/3 grün (mit Token), `npx vitest run` grün, und beide in
`companion-app/README.md` dokumentiert sind.
5. Committen; `AGENTS.md` Block A Test-Punkt abhaken.
---
## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11)
> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
> angeschlossene iPhone und die Apple-ID des Besitzers.
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
(Keychain + QR-Scan).
**Stufe 1 — PWA (kein neues Werkzeug):**
1. `companion-app/public/manifest.webmanifest` (Name „DataMetric360", `display: "standalone"`,
Themenfarbe `#161b23`, neutrale Icons 192/512px — NICHT die Audi-Ringe als App-Icon, der
Homescreen ist „außen"; neutrales DM360-Monogramm erzeugen) + Verweis im `index.html`.
2. Erreichbarkeit fürs Handy: solange Phase 12 nicht fertig ist, über Tailscale
(`npm run build`, Ausgabe z. B. über HA `/local/` oder `vite preview --host` im LAN testen).
3. ✅ Fertig wenn: „Zum Home-Bildschirm" auf dem iPhone die App randlos startet und Login +
Datenanzeige funktionieren.
**Stufe 2 — Capacitor:**
4. `npm i -D @capacitor/cli && npm i @capacitor/core @capacitor/ios @capacitor/android`
(im Workspace für companion-app), `npx cap init DataMetric360 app.datametric360 --web-dir dist`,
`npx cap add ios`, `npx cap add android`.
5. **Sichere Token-Ablage:** Capacitor-Secure-Storage-Plugin (z. B.
`@aparajita/capacitor-secure-storage`) als neue `Ablage`-Implementierung; über den vorhandenen
Hook `ablageSetzen()` der Datenschicht einhängen, wenn `umgebungErkennen()` „capacitor" meldet.
Browser-Fall bleibt unverändert `BrowserAblage`.
6. **QR-Scan:** Barcode-Plugin (`@capacitor-mlkit/barcode-scanning`) + Kamera-Berechtigung;
Gegenstück: kleine statische Seite unter HA `/local/dm360-qr.html`, die den Token **clientseitig**
(Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt. Inhalt des QR: JSON
`{"url": "...", "token": "..."}`. Wenn das zusammen > 1 Tag Aufwand wird: weglassen —
manuelles Einfügen ist die beschlossene, ausreichende Lösung.
7. **iOS-Sideload:** `npx cap open ios`, in Xcode Signing mit eigener Apple-ID, aufs Gerät bauen.
⚠️ Entscheidungspunkt für den Besitzer: mit kostenlosem Apple-Konto läuft die Signatur nach
**7 Tagen** ab (App neu aufspielen); ein bezahltes Entwicklerkonto (99 €/Jahr) macht 1 Jahr.
Bei 7-Tage-Schmerz ist die PWA-Stufe die Alltagslösung, Capacitor das Extra für Keychain/QR.
8. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und
neu installieren → Token weg; Backup/Restore-Verhalten notieren), QR-Einrichtung funktioniert
oder ist dokumentiert entfallen.
9. Committen; `AGENTS.md` Block A Punkte QR/Secure-Storage abhaken.
---
## Phase 11 — HA-Einbettung und Ablösung des alten Panels
**Ziel:** Die neue App läuft im HA-Seitenmenü; das alte Panel ist archiviert.
1. **Build in HA ausliefern:** `npm run build`, Ausgabe nach `/config/www/dm360/` der
Produktivinstanz (Weg analog `update.ps1`; das Script um diesen Ordner erweitern).
2. `configuration.yaml`: `panel_iframe`-Eintrag (Titel „Mein Audi", `mdi:car-sports`,
URL `/local/dm360/index.html`). Der `panel_custom`-Block des alten Panels bleibt zunächst
parallel bestehen (zweiter Menüpunkt „Mein Audi (alt)").
3. **Parallelbetrieb mindestens 2 Wochen:** beide Panels zeigen dieselben Daten (gleiche
Entitäten/Services — Abweichungen sind Portierungsfehler; prüfen: Übersichtszahlen, eine
Fahrt anlegen/löschen, ein Beleg-Upload).
4. **Ablösung:** alten `panel_custom`-Block entfernen; Dateien NICHT löschen, sondern
verschieben: `homeassistant/www/audi-dashboard-app.js` → `homeassistant/archiv/` (plus
Panel-Stub und CSS), README-Hinweis dort ablegen. Beschlossene Regel: **archivieren, nie
löschen** (`COMPANION_APP_ARCHITECTURE.md` §1).
5. ✅ Fertig wenn: Neues Panel im HA-Menü, altes entfernt aber archiviert, `SPECIFICATION.md`
bekommt eine Kopfnotiz „beschreibt das archivierte Panel; aktuell ist companion-app/".
6. Committen; `AGENTS.md` Block D abhaken, Statustabelle umstellen.
---
## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy 🟡 VORBEREITET
> Fertige Freigabeliste samt Prüfbefehlen: `homeassistant/REVERSE_PROXY.md`.
> Ausführung braucht Cloudflare-Konto und die Nameserver-Umstellung.
**Ziel:** Die App funktioniert von unterwegs (Mobilfunk), ohne dass HA selbst erreichbar ist.
Referenz: `COMPANION_APP_ARCHITECTURE.md` §4/§5. Teilweise Besitzer-Aufgaben (Konten/DNS).
1. **[Besitzer] Nameserver umstellen:** `datametric360.app` bei all-inkl auf die
Cloudflare-Nameserver zeigen lassen (Cloudflare-Konto → Site hinzufügen → „Full setup";
Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.app` die
Cloudflare-Server liefert.
2. **Hostnamen festlegen** (Empfehlung aus dem Architekturdokument übernehmen): App unter
`https://datametric360.app`, API unter `https://api.datametric360.app` — getrennter
API-Hostname hält die Allowlist übersichtlich. Entscheidung in `AGENTS.md` protokollieren.
3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, zwei öffentliche Hostnamen:
`datametric360.app` → Reverse-Proxy-Port (statische App-Dateien + nichts weiter),
`api.datametric360.app` → Reverse-Proxy-Port (API-Pfade). Kein Router-Port wird geöffnet.
4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (Standard-Empfehlung; Traefik nur, falls NPM
an Grenzen stößt — Entscheidung dokumentieren). Allowlist ausschließlich:
- `GET /api/states/pyscript.audi_dashboard_*` und `GET /api/states/pyscript.reifen_*`
- `POST /api/services/pyscript/audi_dashboard_*`
- `GET /api/` (Verbindungsprüfung) und der WebSocket-Pfad `/api/websocket`
Alles andere (v. a. `/auth`, `/lovelace`, `/config`, `/api/config`) wird geblockt.
⚠️ Ehrliche Einschränkung dokumentieren: `/api/websocket` lässt sich nicht pfadgenau
beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht bleibt der LLAT
(ohne Token keine Verbindung); das ist die im Architekturdokument akzeptierte Abwägung.
5. **Prüfen von außen** (Mobilfunk, VPN aus): App lädt, Login klappt, `curl` auf einen
Nicht-Allowlist-Pfad (z. B. `/auth/authorize`) liefert 403/404; HA-Port 8123 ist von außen
nicht erreichbar.
6. ✅ Fertig wenn: Schritt 5 vollständig besteht. Die endgültige Allowlist wortwörtlich in
`COMPANION_APP_ARCHITECTURE.md` §5.1 eintragen (dort als offener Punkt vorgemerkt) und in
`AGENTS.md` Block B abhaken.
Schritte 24 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden.
---
## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi
> **Diese Phase ist gegenstandslos.** Die FMM003-Hardware ist seit 2026-08-13 in Betrieb, aber über
> den in `AGENTS.md` (§ FMM003/Datenpfad) beschriebenen Weg: **flespi (natives Codec8/TCP, IMEI-
> Auth), nicht MQTT/Mosquitto.** Die Schritte unten (eigene CA, Mosquitto-Add-on, Zertifikate,
> `mosquitto_sub`-Mitschnitt) beschreiben die zuerst geplante, dann verworfene Variante — stehen
> nur noch als Entscheidungsprotokoll. `homeassistant/FMM003_MAPPING.md` (unten referenziert)
> wurde beim Merge 2026-08-13 deshalb bewusst nicht übernommen. Maßgeblich ist ausschließlich
> `AGENTS.md`.
**Ziel (nicht mehr aktuell):** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über
die Zündung. Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder
simulieren.**
1. **Mosquitto-Add-on** in HA installieren, Benutzer für das Gerät anlegen.
2. **Zertifikate:** kleine eigene CA (drei `openssl`-Befehle: CA-Schlüssel+Zertifikat,
Server-Zertifikat für den Broker, Client-Zertifikat fürs Gerät); Broker auf 8883/TLS;
CA-/Client-Dateien im Teltonika Configurator unter *Security* hinterlegen. Ohne Zertifikate
verweigert der FMM003 MQTT.
3. **[Besitzer] Broker-Erreichbarkeit entscheiden:** Portfreigabe 8883 (einfach; TLS+Login
davor) **oder** VPS-Broker mit Mosquitto-Bridge über Tailscale (keine Freigabe, mehr Aufwand).
Entscheidung + Begründung in `COMPANION_APP_ARCHITECTURE.md` §5.2a und `AGENTS.md` nachtragen.
4. **Gerät konfigurieren:** Firmware-Version notieren (Codec JSON ist firmwareabhängig!);
*System → Data Protocol → Codec JSON*; *GPRS → MQTT* mit Broker-IP/Port/Login.
5. **Erste echte Nachricht mitschneiden:** `mosquitto_sub -h <broker> -p 8883 --cafile ca.crt
-u <nutzer> -P <pw> -t '#' -v > mitschnitt.txt` — mit Zündung an/aus, kurzer Fahrt.
Aus dem Mitschnitt die **Feldzuordnungstabelle** bauen (JSON-Feld → Bedeutung → Einheit) und
als `homeassistant/FMM003_MAPPING.md` einchecken (ohne echte Positionsdaten im Beispiel!).
6. **HA-MQTT-Integration:** aus der Tabelle `mqtt:`-Sensoren/`device_tracker` in der
HA-Konfiguration definieren (Zündung, Geschwindigkeit, Drehzahl, Tankfüllstand, Position, km).
7. **Fahrterkennung umstellen:** neues pyscript `fahrterkennung_fmm003.py` nach dem Muster von
`fahrterkennung.py` — Trigger ist die Zündungs-Entität (1→0 mit Pausentoleranz via
`task.unique()`/`task.sleep()`, Ende-Zeitpunkt auf den echten Zündung-aus-Moment rückdatieren;
`fahrten_pausenzeit_min` aus dem Profil weiterverwenden). Erst parallel laufen lassen
(schreibt in eine Test-Datei), vergleichen, dann scharf schalten und
`sensor.iphone_wifi_connection` aus `einstellungen.py` entfernen.
8. **Live-Screen aktivieren:** Feature-Schalter aus Phase 7 umlegen, Live-Felder anbinden;
GPS-Route der laufenden Fahrt aufzeichnen und im Fahrt-Detail echte Tracks statt der
Start/Ende-Marker zeigen (damit stirbt der letzte Rest von `fakeTrack()` endgültig).
9. ✅ Fertig wenn: eine echte Fahrt automatisch erkannt, mit km und Route gespeichert und in der
App (Liste, Detail mit echter Route, Statistik) korrekt angezeigt wird; VAG-Integration und
WLAN-Trigger sind entfernt; `AGENTS.md` Block B komplett abgehakt.
---
## Anhang A — Nachschlagereferenz für das ausführende Modell
**Entitäten (lesen):** `pyscript.audi_dashboard_profil`, `…_fahrten`, `…_tankvorgaenge`,
`…_fahrzeugstatus`, `…_batterieverlauf`, `…_beleg_ergebnis`, `…_update_status`,
`pyscript.reifen_sommer_km`, `…_winter_km`, `…_aktiver_satz`. Nutzdaten stets im Attribut `daten`.
**Services (schreiben, Domain `pyscript`):** `audi_dashboard_profil_schreiben`,
`…_beleg_hochladen`, `…_tankvorgang_manuell`, `…_tankvorgang_aktualisieren`,
`…_tankvorgang_loeschen`, `…_fahrt_manuell_anlegen`, `…_fahrt_loeschen`, `…_bild_hochladen`,
`…_bild_loeschen`, `…_reifen_wechseln`, `…_backup_jetzt`, `…_backup_wiederherstellen`,
`…_jetzt_aktualisieren`, `…_screening_jetzt`, `…_update_pruefen`, `…_update_installieren`.
Signaturen: `SPECIFICATION.md` §5. **Service-Aufrufe geben keine Werte zurück** — Ergebnisse
kommen asynchron über Entitäten (Muster: Beleg-Upload).
**Stolperfallen:**
- Profil wird immer **als Ganzes** geschrieben — nie Teil-Updates erfinden.
- `edited_fields` eines Datensatzes schützt Felder vor automatischen Überschreibungen — beim
Portieren von Bearbeiten-Formularen beibehalten.
- Sensorwerte `"unknown"`/`"unavailable"` = `None` behandeln, nie raten (Muster
`zustand_oder_none()`).
- pyscript: kein nacktes `open()`/`with` — `task.executor`-Muster aus `modules/profil.py`.
- HA-Attribute haben ~16 KB-Grenze (`frontend_veroeffentlichung.py`) — keine großen Blobs in
Entitäten stopfen.
- `fahrten.jsonl`/`tankvorgaenge.jsonl` sind aktuell **leer** — Leerzustände sind der erste
Eindruck der App, nicht ein Randfall.
## Anhang B — Definition of Done (gesamt)
Die App gilt als fertig, wenn: alle Screens aus Phase 7 in beiden Layouts funktionieren; Onboarding,
Offline-Queue und Beleg-Upload gegen die Produktiv-HA laufen; die App als PWA und (falls Capacitor
umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.app`
funktioniert, ohne dass HA exponiert ist; das alte Panel archiviert ist; und `AGENTS.md` den
Endstand widerspiegelt (alle Blöcke AD abgehakt oder begründet gestrichen).
+6
View File
@@ -2,3 +2,9 @@ node_modules/
dist/
*.local
.DS_Store
# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
# getrackt.
ios/
android/
+55 -60
View File
@@ -1,81 +1,76 @@
# DataMetric360 — companion app
# companion-app — DataMetric360
Private vehicle app for one car. Ships three ways from one codebase: native iOS and Android via
Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the
decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md).
Private Fahrzeug-App für einen Audi RS 4 Avant competition. Läuft als Web-App
im Browser, als PWA auf dem Homescreen, später über Capacitor nativ auf
iOS/Android und eingebettet als Iframe im Home-Assistant-Dashboard.
## Status
**Kein eigenes Backend.** Die App spricht direkt mit der REST- und
WebSocket-Schnittstelle von Home Assistant und ruft dieselben
`pyscript.audi_dashboard_*`-Dienste auf wie das bestehende Panel. Alle Daten
liegen auf dem eigenen Server; das Gerät hält nur den Zugangstoken und einen
Zwischenspeicher für die Offline-Anzeige.
**Data layer only.** No UI yet — the screens come from the Claude Design draft
([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top
of `design-system/`'s React components once that draft settles. This package was written first on
purpose: how the app talks to Home Assistant doesn't depend on what the screens look like, so it
survives every design iteration untouched.
Architektur: `../COMPANION_APP_ARCHITECTURE.md` · Umsetzungsschritte:
`../UMSETZUNGSPLAN.md` · Projektstand: `../AGENTS.md`
Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage
adapter, and the Audi brand assets (fonts/rings/badges — those come from `homeassistant/www/` at
implementation time, never into `design-system/`, see the licence note in the architecture doc).
## Loslegen
## Layout
```
src/api/
├── types.ts HA state shapes + domain types (Fahrt, Tankvorgang, …) + the entity-ID table
├── umgebung.ts runtime detection (capacitor/iframe/browser), credential storage, URL helpers
├── rest.ts REST client — replaces hass.states / hass.callService
├── live.ts WebSocket client — push updates, auto-reconnect with backoff
├── warteschlange.ts offline queue for writes made without a connection
└── index.ts DataMetricApi — ties the three together, exposes the domain operations
scripts/smoke.ts verification against a running HA instance
```bash
npm install # im Repo-Wurzelverzeichnis, nicht hier (npm-Workspace)
npm run build:ds # Design-System bauen, die App importiert aus dessen dist/
npm run dev --workspace datametric360
```
Comments are in German, matching the rest of the project.
Beim ersten Start fragt die App nach Server-Adresse und Zugangstoken. Für die
Entwicklung setzt `../testumgebung/aufsetzen.sh` eine vollständige
Home-Assistant-Instanz mit dem echten Backend auf.
## What replaces what
## Aufbau
The old panel got a `hass` object injected by `panel_custom`. That object only exists inside the HA
frontend, which is exactly why the old panel can't run as a standalone app. The mapping:
| Old panel | Here |
| Ordner | Inhalt |
|---|---|
| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. |
| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods |
| automatic re-render on state push | `live.aufZustand(...)` |
| `src/api/` | Datenschicht: REST, WebSocket mit Wiederverbindung, Offline-Warteschlange, Zugangsdaten |
| `src/daten/` | Fachlogik: Profil-Umrechnung, Statistik, Service-Prognose, Datenkontext |
| `src/screens/` | Die 21 Seiten der App |
| `src/stile/` | Layout, Bildschirmbausteine, Audi-Schrift |
| `src/assets/audi/` | Schrift, Vier-Ringe, Typenschilder — **lizenzpflichtig, siehe unten** |
| `src/tests/` | Testaufbau und Beispieldaten |
Same entities, same pyscript services, same backend files written — only the transport changes.
Gestaltung kommt vollständig aus `@audi-dash/ui` (`../design-system/`). Neue
Komponenten entstehen dort, nicht hier.
Every **write** goes through the queue rather than straight to REST. That's what makes offline edits
behave the same as online ones, just delayed: a receipt photographed in a dead zone is persisted and
sent when the connection returns, surviving an app restart in between.
## Commands
Node is installed at `C:\Program Files\nodejs` but is **not on PATH** — prefix it:
## Prüfen
```bash
$env:Path = "C:\Program Files\nodejs;" + $env:Path
npm run typecheck # TypeScript, sehr streng (exactOptionalPropertyTypes u. a.)
npm run test # 90 Tests: Rechenlogik, Layoutwechsel, alle Seiten gerendert
npm run smoke # Adressbildung und Endpunkte, ohne Token
npm run smoke:auth # gegen die laufende Testinstanz, mit Token
npm run build # Produktionsbündel
```
```bash
npm run typecheck
```
`smoke:auth` liest Adresse und Token aus `.env.local` (schreibt
`../testumgebung/aufsetzen.sh`, gitignored). Ohne die Datei überspringt der
Test sich selbst, statt fehlzuschlagen.
```bash
npm run smoke
```
Die Rendertests decken alle 21 Seiten in beiden Layouts ab — schmal mit
Tab-Leiste, breit mit Seitenleiste.
`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a
different base URL as the first argument.
## Rechtlicher Hinweis
## Verification status (2026-08-10)
`src/assets/audi/` enthält die Hausschrift Audi Type, das Vier-Ringe-Zeichen
und die Modell-Typenschilder. Diese sind lizenz- beziehungsweise
markenrechtlich geschützt und ausschließlich für diese eine private,
unveröffentlichte Installation freigegeben (`../bauauftrag.md` §8/§12).
`tsc --noEmit` clean under `strict` plus `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
All 7 smoke checks pass against the running test container, covering URL normalisation, the
http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401,
and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow
is built around.
Daraus folgt: **Die App wird nur seitlich installiert, nie in einem App Store
veröffentlicht**, und dieses Repository bleibt privat. `../design-system/`
kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird —
dort niemals Markendateien ablegen.
**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real
round-trip. Those need a Long-Lived Access Token, which is a deliberate manual step (see the LLAT
provisioning decision in the architecture doc). To do it: create a token in the HA profile page, then
extend `scripts/smoke.ts` with authenticated cases.
## Noch offen
- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
- QR-Einrichtung als Alternative zum Einfügen des Tokens
- Live-Ansicht der laufenden Fahrt: gebaut, aber über
`src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
+44
View File
@@ -0,0 +1,44 @@
import type { CapacitorConfig } from "@capacitor/cli"
/**
* Capacitor-Konfiguration für die native Hülle.
*
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
* ausschließlich für diese eine private Installation freigegeben.
*
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
*/
const konfiguration: CapacitorConfig = {
appId: "app.datametric360",
appName: "DataMetric360",
webDir: "dist",
server: {
// https, damit die App als sicherer Ursprung gilt — sonst verweigern
// Browser-Bausteine wie die Zwischenablage ihren Dienst.
androidScheme: "https",
iosScheme: "https",
},
ios: {
// Die App zeichnet bis unter die Statusleiste und hält den sicheren
// Bereich selbst über env(safe-area-inset-*) frei.
contentInset: "never",
backgroundColor: "#161b23",
},
android: {
backgroundColor: "#161b23",
},
plugins: {
// Leitet fetch() über die native HTTP-Schicht statt über die WebView.
//
// Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
// liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
// Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
// ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
CapacitorHttp: { enabled: true },
},
}
export default konfiguration
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#161b23" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./symbol.svg" type="image/svg+xml" />
<!-- iOS wertet das Manifest nicht aus und braucht diese beiden Angaben,
damit "Zum Home-Bildschirm" ein eigenes Symbol und keine Browserleiste
zeigt. -->
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DataMetric360" />
<title>DataMetric360</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27 -3
View File
@@ -2,14 +2,38 @@
"name": "datametric360",
"version": "0.1.0",
"private": true,
"description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.",
"description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Datenschicht plus Oberfläche auf Basis von @audi-dash/ui.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"smoke": "node --experimental-strip-types scripts/smoke.ts"
"smoke": "node --experimental-strip-types scripts/smoke.ts",
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
"test": "vitest run"
},
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@audi-dash/ui": "*",
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.0",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@capacitor/cli": "^8.5.0",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.12",
"@types/node": "^24.13.3",
"typescript": "^5.7.2"
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"name": "DataMetric360",
"short_name": "DM360",
"description": "Fahrzeugdaten, Fahrtenbuch und Tankvorgänge vom eigenen Server.",
"lang": "de",
"dir": "ltr",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#161b23",
"theme_color": "#161b23",
"icons": [
{
"src": "./symbol.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "./symbol-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./symbol-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'>
<rect width='512' height='512' rx='114' fill='#161b23'/>
<path d='M150 176h74c48 0 78 31 78 80s-30 80-78 80h-74z' fill='none' stroke='#ffffff' stroke-width='26' stroke-linejoin='round'/>
<path d='M330 336V176l46 54 46-54v160' fill='none' stroke='#F50537' stroke-width='26' stroke-linecap='round' stroke-linejoin='round'/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

+189
View File
@@ -0,0 +1,189 @@
/* Rauchtest mit echtem Zugangstoken gegen die laufende HA-Testinstanz.
Ergänzt scripts/smoke.ts um alles, was ohne Anmeldung nicht prüfbar ist:
echte Lesevorgänge, ein echter Dienstaufruf und ein vollständiger Umlauf
durch die Warteschlange.
Zugangsdaten kommen aus companion-app/.env.local (schreibt
testumgebung/aufsetzen.sh). Ohne sie bricht der Test mit einem Hinweis ab,
ohne zu scheitern - damit er in einer Umgebung ohne Testinstanz nicht
fälschlich rot wird.
Aufruf: npm run smoke:auth */
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "node:path"
import { ENTITAETEN } from "../src/api/types.ts"
import { HassRest } from "../src/api/rest.ts"
import { Warteschlange } from "../src/api/warteschlange.ts"
import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts"
/* ------------------------------------------------------ Zugangsdaten */
const hier = dirname(fileURLToPath(import.meta.url))
const umgebungsdatei = resolve(hier, "../.env.local")
function umgebungLesen(): Record<string, string> {
try {
const inhalt = readFileSync(umgebungsdatei, "utf8")
const werte: Record<string, string> = {}
for (const zeile of inhalt.split("\n")) {
const treffer = /^([A-Z_]+)=(.*)$/.exec(zeile.trim())
if (treffer?.[1] && treffer[2] !== undefined) werte[treffer[1]] = treffer[2]
}
return werte
} catch {
return {}
}
}
const umgebung = umgebungLesen()
const BASIS = umgebung["VITE_TEST_URL"] ?? "http://localhost:18123"
const TOKEN = umgebung["VITE_TEST_TOKEN"] ?? process.env["VITE_TEST_TOKEN"] ?? ""
if (!TOKEN) {
console.log("übersprungen: kein Token in companion-app/.env.local")
console.log(" testumgebung/aufsetzen.sh ausführen, dann erneut versuchen.")
process.exit(0)
}
/* ------------------------------------------------------------ Rahmen */
let fehler = 0
function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void {
console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`)
if (!bedingung) fehler += 1
}
/** Ablage im Arbeitsspeicher — Node hat kein localStorage. */
class SpeicherAblage implements Ablage {
#werte = new Map<string, string>()
async lesen(schluessel: string) {
return this.#werte.get(schluessel) ?? null
}
async schreiben(schluessel: string, wert: string) {
this.#werte.set(schluessel, wert)
}
async loeschen(schluessel: string) {
this.#werte.delete(schluessel)
}
}
ablageSetzen(new SpeicherAblage())
const zugang = { basisUrl: BASIS, token: TOKEN }
const rest = new HassRest(zugang)
/* ------------------------------------------------------------- Lesen */
try {
const antwort = await rest.verbindungPruefen()
pruefe("verbindungPruefen mit Token", typeof antwort.message === "string", antwort.message)
} catch (e) {
pruefe("verbindungPruefen mit Token", false, e instanceof Error ? e.message : String(e))
}
try {
const profil = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.profil)
pruefe(
"Profil liefert die erwarteten Abschnitte",
Boolean(profil["fahrzeug"] && profil["einstellungen"] && profil["reifen"]),
Object.keys(profil).join(", "),
)
} catch (e) {
pruefe("Profil lesen", false, e instanceof Error ? e.message : String(e))
}
try {
const status = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.fahrzeugstatus)
/* Die Feldnamen sind der Grund für diesen Test: ein früherer Entwurf der
Typen hatte tank_prozent/sicher_abgestellt/sicherheit angenommen, das
Backend schreibt aber tankprozent/gesichert/sicherheitscheck. */
pruefe(
"Fahrzeugstatus benutzt die erwarteten Feldnamen",
"tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
Object.keys(status).slice(0, 6).join(", "),
)
pruefe(
"Sicherheitscheck liefert alle 16 Einzelprüfungen",
Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16,
`${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`,
)
} catch (e) {
pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
}
for (const [name, id] of [
["Fahrten", ENTITAETEN.fahrten],
["Tankvorgänge", ENTITAETEN.tankvorgaenge],
] as const) {
try {
const liste = await rest.datenLesen<unknown[]>(id)
pruefe(`${name} sind eine Liste`, Array.isArray(liste), `${liste.length} Einträge`)
} catch (e) {
pruefe(`${name} lesen`, false, e instanceof Error ? e.message : String(e))
}
}
/* -------------------------------------------------------- Dienstaufruf */
try {
await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
} catch (e) {
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
}
/* ------------------------------------------------------ Warteschlange */
try {
const warteschlange = new Warteschlange(rest)
await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest")
/* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
ist schon aktiv). Also warten, bis die Schlange tatsächlich leer ist. */
const frist = Date.now() + 10_000
while (warteschlange.anzahl > 0 && Date.now() < frist) {
await new Promise((weiter) => setTimeout(weiter, 100))
}
pruefe(
"Warteschlange arbeitet einen Auftrag vollständig ab",
warteschlange.anzahl === 0,
`${warteschlange.anzahl} verblieben`,
)
} catch (e) {
pruefe("Warteschlangen-Umlauf", false, e instanceof Error ? e.message : String(e))
}
/* ----------------------------------------- WebSocket mit Anmeldung */
try {
const ergebnis = await new Promise<string>((aufloesen, ablehnen) => {
const ws = new WebSocket(websocketUrl(BASIS))
const frist = setTimeout(() => {
ablehnen(new Error("Zeitüberschreitung"))
ws.close()
}, 10_000)
ws.onmessage = (nachricht) => {
const daten = JSON.parse(String(nachricht.data)) as { type: string }
if (daten.type === "auth_required") {
ws.send(JSON.stringify({ type: "auth", access_token: TOKEN }))
} else if (daten.type === "auth_ok" || daten.type === "auth_invalid") {
clearTimeout(frist)
aufloesen(daten.type)
ws.close()
}
}
ws.onerror = () => {
clearTimeout(frist)
ablehnen(new Error("WebSocket-Fehler"))
}
})
pruefe("WebSocket meldet sich mit dem Token an", ergebnis === "auth_ok", ergebnis)
} catch (e) {
pruefe("WebSocket-Anmeldung", false, e instanceof Error ? e.message : String(e))
}
console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`)
process.exitCode = fehler === 0 ? 0 : 1
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation"
import { useTabBeschriftung, useTheme } from "./theme"
import { Einrichtung } from "./screens/Einrichtung"
import { Hinweisleiste } from "./screens/Hinweisleiste"
import { SeiteFuer } from "./screens/register"
import { zugangMerken } from "./screens/zugang"
import "./stile/rahmen.css"
import "./stile/screens.css"
type Startzustand = "pruefen" | "einrichten" | "bereit"
/**
* Wurzel der App. Entscheidet zuerst, ob Zugangsdaten vorliegen — ohne sie
* gibt es nichts anzuzeigen außer der Ersteinrichtung. Danach hängt alles
* unter dem Datenkontext.
*/
export function App() {
const [theme] = useTheme()
const [start, setzeStart] = useState<Startzustand>("pruefen")
// Genau eine Instanz für die Lebensdauer der App: sie hält die
// WebSocket-Verbindung und die Warteschlange.
const api = useMemo(() => new DataMetricApi(), [])
useEffect(() => {
void zugangLesen().then(async (zugang) => {
// Adresse für Bild-URLs merken, bevor der erste Screen rendert.
if (zugang) await zugangMerken()
setzeStart(zugang ? "bereit" : "einrichten")
})
}, [])
return (
<div className="ads-root dm-wurzel" data-theme={theme}>
{start === "pruefen" && <div className="dm-laden">Einen Moment </div>}
{start === "einrichten" && <Einrichtung fertig={() => setzeStart("bereit")} />}
{start === "bereit" && (
<DatenAnbieter api={api}>
<AngemeldeteApp beiAbmeldung={() => setzeStart("einrichten")} />
</DatenAnbieter>
)}
</div>
)
}
function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
const [route, setzeRoute] = useState<Route>({ name: "home" })
const [tabBeschriftung, setzeTabBeschriftung] = useTabBeschriftung()
const daten = useDaten()
const geheZu = useCallback((name: SeitenName, id?: string) => {
setzeRoute(id === undefined ? { name } : { name, id })
document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 })
}, [])
// Ein abgelaufener oder zurückgezogener Token führt zurück in die
// Ersteinrichtung statt in eine endlose Fehlerschleife.
useEffect(() => {
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
}, [daten.verbindung, beiAbmeldung])
return (
<Shell
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
hinweis={
<Hinweisleiste verbindung={daten.verbindung} warteschlange={daten.warteschlange} />
}
>
<SeiteFuer
route={route}
geheZu={geheZu}
tabBeschriftung={tabBeschriftung}
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
</Shell>
)
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Abnahmekriterien aus Phase 5 des Umsetzungsplans: Die App muss beide
* Layouts beherrschen und die Zurück-Navigation über die ZURUECK-Tabelle
* korrekt auflösen.
*/
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { Shell } from "./Shell"
import { bereichVon } from "./navigation"
import { bildschirmbreiteSetzen } from "./tests/aufbau"
function rahmen(routeName: Parameters<typeof bereichVon>[0], geheZu = vi.fn()) {
render(
<Shell route={{ name: routeName }} geheZu={geheZu} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
return geheZu
}
describe("Shell", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
it("zeigt auf schmalen Bildschirmen die Tab-Leiste unten", () => {
const { container } = render(
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
expect(container.querySelector(".ads-tabbar")).not.toBeNull()
expect(container.querySelector(".dm-seitenleiste")).toBeNull()
})
it("zeigt auf breiten Bildschirmen die Seitenleiste statt der Tab-Leiste", () => {
bildschirmbreiteSetzen(1280)
const { container } = render(
<Shell route={{ name: "home" }} geheZu={vi.fn()} tabBeschriftung>
<p>Inhalt</p>
</Shell>,
)
expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
expect(container.querySelector(".ads-tabbar")).toBeNull()
})
it("führt der Zurück-Pfeil zur übergeordneten Seite", () => {
const geheZu = rahmen("beitrag")
fireEvent.click(screen.getByLabelText("Zurück"))
// beitrag -> vers laut ZURUECK-Tabelle
expect(geheZu).toHaveBeenCalledWith("vers")
})
it("blendet den Zurück-Pfeil auf Hauptbereichen aus", () => {
rahmen("trips")
expect(screen.queryByLabelText("Zurück")).toBeNull()
})
it("hebt den Hauptbereich hervor, zu dem eine Unterseite gehört", () => {
// schutz -> vertragsdetails -> vers -> audi
expect(bereichVon("schutz")).toBe("audi")
expect(bereichVon("fill")).toBe("fuel")
expect(bereichVon("trip")).toBe("trips")
})
it("erreicht die Einstellungen über das Zahnrad, nicht über die Tab-Leiste", () => {
const geheZu = rahmen("home")
fireEvent.click(screen.getByLabelText("Einstellungen"))
expect(geheZu).toHaveBeenCalledWith("einst")
})
})
+148
View File
@@ -0,0 +1,148 @@
/**
* Rahmen der App: Kopfzeile, Navigation, Inhaltsfläche.
*
* Zwei Layouts aus derselben Zusammensetzung — die entscheidende Anforderung
* aus dem Design-Auftrag: schmal (Handy) mit Tab-Leiste unten, breit (Tablet,
* eingebettet in Home Assistant) mit Seitenleiste links. Der Umschaltpunkt
* liegt bei 1000px.
*/
import type { ReactNode } from "react"
import { TabBar } from "@audi-dash/ui"
import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm"
import { VierRinge } from "./screens/Typenschild"
import type { Hauptbereich, Route, SeitenName } from "./navigation"
import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation"
import {
SymbolAudi,
SymbolFahrten,
SymbolStatistik,
SymbolTanken,
SymbolUebersicht,
SymbolZahnrad,
SymbolZurueck,
} from "./symbole"
export interface ShellProps {
route: Route
geheZu: (name: SeitenName, id?: string) => void
tabBeschriftung: boolean
/** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */
hinweis?: ReactNode | undefined
/** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */
children: ReactNode
}
const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [
{ key: "home", label: "Übersicht", icon: <SymbolUebersicht /> },
{ key: "audi", label: "Mein Audi", icon: <SymbolAudi /> },
{ key: "trips", label: "Fahrten", icon: <SymbolFahrten /> },
{ key: "stat", label: "Statistik", icon: <SymbolStatistik /> },
{ key: "fuel", label: "Tanken", icon: <SymbolTanken /> },
]
export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) {
const breit = useBreiterBildschirm()
const aktiverBereich = bereichVon(route.name)
const zurueckZiel = ZURUECK[route.name]
const kopf = (
<header className="dm-kopf">
{zurueckZiel && !breit ? (
<button
type="button"
className="dm-kopf__zurueck"
aria-label="Zurück"
onClick={() => geheZu(zurueckZiel)}
>
<SymbolZurueck groesse={22} />
</button>
) : null}
<div className="dm-kopf__titel">
<span className="ads-eyebrow">DataMetric360</span>
<div className="ads-title">{TITEL[route.name]}</div>
</div>
<button
type="button"
className="dm-kopf__aktion"
aria-label="Einstellungen"
aria-current={route.name === "einst" ? "page" : undefined}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad groesse={22} />
</button>
</header>
)
if (breit) {
return (
<>
<nav className="dm-seitenleiste" aria-label="Hauptbereiche">
<div className="dm-seitenleiste__marke">
<VierRinge hoehe={18} />
<span className="ads-eyebrow">DataMetric360</span>
</div>
{BEREICHE.map((bereich) => (
<button
key={bereich.key}
type="button"
className={
bereich.key === aktiverBereich
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
: "dm-seitenleiste__punkt"
}
aria-current={bereich.key === aktiverBereich ? "page" : undefined}
onClick={() => geheZu(bereich.key)}
>
{bereich.icon}
<span>{bereich.label}</span>
</button>
))}
<div className="dm-seitenleiste__fuss">
<button
type="button"
className={
route.name === "einst"
? "dm-seitenleiste__punkt dm-seitenleiste__punkt--an"
: "dm-seitenleiste__punkt"
}
onClick={() => geheZu("einst")}
>
<SymbolZahnrad />
<span>Einstellungen</span>
</button>
</div>
</nav>
<div className="dm-spalte">
{hinweis}
{!istHauptbereich(route.name) && zurueckZiel ? (
<div className="dm-brotkrume">
<button type="button" onClick={() => geheZu(zurueckZiel)}>
<SymbolZurueck groesse={16} />
<span>{TITEL[zurueckZiel]}</span>
</button>
</div>
) : null}
<main className="dm-scroll dm-inhalt dm-inhalt--breit">{children}</main>
</div>
</>
)
}
return (
<div className="dm-spalte">
{hinweis}
{kopf}
<main className="dm-scroll dm-inhalt">{children}</main>
<TabBar
items={BEREICHE}
activeKey={aktiverBereich}
onChange={(schluessel) => geheZu(schluessel as Hauptbereich)}
iconOnly={!tabBeschriftung}
className="dm-tableiste"
/>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
/* ================================================================
Sichere Ablage auf dem Gerät (iOS Keychain / Android Keystore)
================================================================
Der Zugangstoken hat vollen Zugriff auf die Home-Assistant-Schnittstelle.
Im Browser bleibt nur localStorage; in der nativen Hülle gehört er in den
Schlüsselbund des Betriebssystems.
**Noch nicht in Betrieb**: das Plugin ist bewusst keine Abhängigkeit,
solange die Capacitor-Hülle nicht gebaut ist. Der Adapter greift deshalb
defensiv auf die Laufzeit zu, statt zu importieren — fehlt das Plugin,
bleibt es bei der Browser-Variante, und die App läuft trotzdem.
In Betrieb nehmen (UMSETZUNGSPLAN.md Phase 10 Stufe 2):
npm i @aparajita/capacitor-secure-storage
Danach ruft main.tsx `nativeAblageEinrichten()` auf. */
import { BrowserAblage, ablageSetzen, umgebungErkennen, type Ablage } from "./umgebung.ts";
/** Form des Plugins, so weit die App sie braucht. */
interface SicherheitsPlugin {
get(optionen: { key: string }): Promise<{ value: string | null }>;
set(optionen: { key: string; value: string }): Promise<void>;
remove(optionen: { key: string }): Promise<void>;
}
function pluginHolen(): SicherheitsPlugin | null {
if (typeof window === "undefined") return null;
const global = window as unknown as {
Capacitor?: { Plugins?: Record<string, unknown> };
};
const plugin = global.Capacitor?.Plugins?.["SecureStorage"];
if (!plugin) return null;
const kandidat = plugin as Partial<SicherheitsPlugin>;
return typeof kandidat.get === "function" &&
typeof kandidat.set === "function" &&
typeof kandidat.remove === "function"
? (plugin as SicherheitsPlugin)
: null;
}
export class NativeAblage implements Ablage {
readonly #plugin: SicherheitsPlugin;
/* Fällt einzeln auf den Browser zurück, wenn ein Aufruf scheitert - besser
eine Sitzung ohne Schlüsselbund als eine App, die nicht startet. */
readonly #ersatz = new BrowserAblage();
constructor(plugin: SicherheitsPlugin) {
this.#plugin = plugin;
}
async lesen(schluessel: string): Promise<string | null> {
try {
const { value } = await this.#plugin.get({ key: schluessel });
return value ?? null;
} catch {
return this.#ersatz.lesen(schluessel);
}
}
async schreiben(schluessel: string, wert: string): Promise<void> {
try {
await this.#plugin.set({ key: schluessel, value: wert });
} catch {
await this.#ersatz.schreiben(schluessel, wert);
}
}
async loeschen(schluessel: string): Promise<void> {
try {
await this.#plugin.remove({ key: schluessel });
} catch {
await this.#ersatz.loeschen(schluessel);
}
}
}
/**
* Schaltet auf die sichere Ablage um, sofern die App nativ läuft und das
* Plugin vorhanden ist. Gibt zurück, ob umgeschaltet wurde — praktisch für
* eine Anzeige in den Einstellungen.
*/
export function nativeAblageEinrichten(): boolean {
if (umgebungErkennen() !== "capacitor") return false;
const plugin = pluginHolen();
if (!plugin) return false;
ablageSetzen(new NativeAblage(plugin));
return true;
}
+45
View File
@@ -11,6 +11,7 @@ export * from "./umgebung.ts";
export * from "./rest.ts";
export * from "./live.ts";
export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
@@ -18,6 +19,20 @@ import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
/** Eingabefelder für Tankvorgänge. Die Namen sind die des Backends
(belegverarbeitung.py), nicht die des Datensatzes - deshalb "liter" statt
"liters" und "kosten" statt "fuel_total_eur". */
export interface TankvorgangFelder {
ts?: string;
liter?: number | null;
kosten?: number | null;
km?: number | null;
ersparnis?: number | null;
station?: string | null;
distanz?: number | null;
kraftstoff?: string | null;
}
/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte
Dashboard über hass.callService erledigt hat. */
export class DataMetricApi {
@@ -78,6 +93,16 @@ export class DataMetricApi {
);
}
/** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */
fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_fahrt_manuell_anlegen",
{ ts_start: tsStart, ts_end: tsEnde, art },
"Fahrt eintragen",
);
}
fahrtLoeschen(tripId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
@@ -87,6 +112,26 @@ export class DataMetricApi {
);
}
/** Felder eines Tankvorgangs, wie sie das Backend erwartet (deutsche
Parameternamen — belegverarbeitung.py). */
tankvorgangAnlegen(felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_manuell",
{ ...felder },
"Tankvorgang eintragen",
);
}
tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
"audi_dashboard_tankvorgang_aktualisieren",
{ tank_id: tankId, ...felder },
"Tankvorgang ändern",
);
}
tankvorgangLoeschen(tankId: string): Promise<unknown> {
return this.warteschlange.einreihen(
"pyscript",
+9 -5
View File
@@ -12,13 +12,17 @@ import type { DatenEntity, HassState } from "./types.ts";
import { zugangLesen, type Zugang } from "./umgebung.ts";
export class ApiFehler extends Error {
constructor(
message: string,
readonly status: number | null,
readonly ursache?: unknown,
) {
/* Eigene Felder statt Parameter-Eigenschaften: Node kann letztere im
Strip-Modus (--experimental-strip-types) nicht übersetzen, und darüber
laufen die Rauchtests dieses Pakets. */
readonly status: number | null;
readonly ursache: unknown;
constructor(message: string, status: number | null, ursache?: unknown) {
super(message);
this.name = "ApiFehler";
this.status = status;
this.ursache = ursache;
}
/** Fehlt oder taugt das Token nicht mehr - dann muss die Oberfläche zurück
+18 -5
View File
@@ -94,12 +94,22 @@ export interface SicherheitsPunkt {
ok: boolean | null;
}
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent",
"gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei
hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit
die Oberfläche still überall undefined gelesen hätte. */
export interface Fahrzeugstatus {
km?: number | null;
tank_prozent?: number | null;
tankprozent?: number | null;
reichweite_km?: number | null;
sicher_abgestellt?: boolean | null;
sicherheit?: SicherheitsPunkt[];
batteriespannung?: number | null;
gesichert?: boolean | null;
sicherheitscheck?: SicherheitsPunkt[];
oelwechsel_faellig_ts?: string | null;
oelwechsel_faellig_km?: number | null;
inspektion_faellig_ts?: string | null;
inspektion_faellig_km?: number | null;
[weitere: string]: unknown;
}
@@ -113,8 +123,11 @@ export interface Profil {
steuer?: Record<string, unknown>;
reifen?: Record<string, unknown>;
service?: Record<string, unknown>;
technik?: Record<string, unknown>;
ausstattung?: Record<string, unknown>;
/* Beide sind Listen von Gruppen, keine Objekte - an der echten
fahrzeugprofil.json abgelesen. Ein früherer Entwurf hatte sie wie die
übrigen Abschnitte als Record deklariert. */
technik?: unknown[];
ausstattung?: unknown[];
[weitere: string]: unknown;
}
+7 -1
View File
@@ -41,8 +41,14 @@ export class Warteschlange {
#geladen = false;
#laeuft = false;
#horcher = new Set<WarteschlangenHorcher>();
/* Bewusst als eigenes Feld statt als Parameter-Eigenschaft im Konstruktor:
Node kann Parameter-Eigenschaften im Strip-Modus (--experimental-strip-types)
nicht übersetzen, und genau darüber laufen die Rauchtests dieses Pakets. */
readonly rest: HassRest;
constructor(private readonly rest: HassRest) {}
constructor(rest: HassRest) {
this.rest = rest;
}
async laden(): Promise<void> {
if (this.#geladen) return;
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" height="100" width="289">
<defs></defs>
<defs>
<clipPath id="a" clipPathUnits="userSpaceOnUse">
<path d="M0 100h289V0H0z"></path>
</clipPath>
</defs>
<g clip-path="url(#a)" transform="matrix(1 0 0 -1 0 100.00040017)">
<path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" height="100" width="289">
<defs></defs>
<defs>
<clipPath id="a" clipPathUnits="userSpaceOnUse">
<path d="M0 100h289V0H0z"></path>
</clipPath>
</defs>
<g clip-path="url(#a)" transform="matrix(1 0 0 -1 0 100.00040017)">
<path d="M239 11.1914c-8.885 0-17.075 3.008-23.624 8.05 6.646 8.489 10.624 19.165 10.624 30.759 0 11.593-3.978 22.269-10.624 30.759 6.549 5.041 14.739 8.05 23.624 8.05 21.399 0 38.809-17.41 38.809-38.809 0-21.4-17.41-38.809-38.809-38.809m-86.625 8.05c6.647 8.489 10.625 19.165 10.625 30.759 0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.885 0 17.075-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.549-5.042-14.739-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-63 0C96.022 27.7304 100 38.4064 100 50.0004c0 11.593-3.978 22.269-10.624 30.759 6.548 5.041 14.74 8.05 23.624 8.05 8.884 0 17.076-3.009 23.624-8.05-6.646-8.49-10.624-19.166-10.624-30.759 0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-8.884 0-17.076 3.008-23.625 8.05m-78.184 30.759c0 21.399 17.41 38.809 38.809 38.809 8.884 0 17.076-3.009 23.624-8.05C66.978 72.2694 63 61.5934 63 50.0004c0-11.594 3.978-22.27 10.624-30.759-6.548-5.042-14.74-8.05-23.624-8.05-21.399 0-38.809 17.409-38.809 38.809M81.5 27.3734c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m63 0c-4.593 6.375-7.309 14.187-7.309 22.627 0 8.439 2.716 16.252 7.309 22.627 4.593-6.375 7.309-14.188 7.309-22.627 0-8.44-2.716-16.252-7.309-22.627m31.5 72.627c-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.568 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-11.932 0-22.896-4.207-31.5-11.207-8.604 7-19.569 11.207-31.5 11.207-27.57 0-50-22.43-50-50 0-27.571 22.43-50 50-50 11.931 0 22.896 4.206 31.5 11.207C90.104 4.2064 101.068.0004 113 .0004c11.931 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 11.932 0 22.896 4.206 31.5 11.207 8.604-7.001 19.568-11.207 31.5-11.207 27.57 0 50 22.429 50 50 0 27.57-22.43 50-50 50" fill="#fff"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284 34"><defs></defs><title>AS_RGB_pos_red</title><g id="boundingbox"><rect class="cls-1" width="284" height="34"></rect></g><g id="AS_4C_0_100_90_0" data-name="AS_4C_0/100/90/0"><path class="cls-2" d="M266,8.67h4.17l.56-4.45h4.62V8.67h8.11v4h-8.11v7c0,2.19.87,3,3.48,3a18,18,0,0,0,4.76-.62v4a20,20,0,0,1-5.87.94c-5.36,0-7.76-2.3-7.76-6.68V12.64h-4Z"></path><path class="cls-2" d="M221.28,17.58c0-5.74,4.21-9.46,11.93-9.46s11.93,3.47,11.93,9.49c0,5.74-4.21,9.46-11.93,9.46S221.28,23.59,221.28,17.58Zm18.26,0c0-3.37-2.54-5.49-6.33-5.49s-6.33,2.08-6.33,5.46,2.54,5.49,6.33,5.49S239.54,21,239.54,17.61Z"></path><path class="cls-2" d="M249.62,8.67h5l.21,3.27a6.66,6.66,0,0,1,6.33-3.75,9.73,9.73,0,0,1,2.57.31v4.73a15.84,15.84,0,0,0-3-.28c-2.92,0-4.76,1.11-5.63,2.37V26.51h-5.46Z"></path><path class="cls-2" d="M167.52,25.43V21.12a31.58,31.58,0,0,0,9.59,1.67c5.15,0,7.58-1.22,7.58-3.3,0-1.64-1.94-2.37-6.92-3.37-6.67-1.33-10.15-3.62-10.15-7.72,0-4.39,3.51-7.62,12.24-7.62a23.91,23.91,0,0,1,9.08,1.56V6.62a30.22,30.22,0,0,0-9.08-1.53c-4.52,0-6.54,1.15-6.54,3.06,0,1.64,2.05,2.37,7,3.38,6.67,1.32,10.15,3.4,10.15,7.75s-4.17,7.79-13.14,7.79A27.76,27.76,0,0,1,167.52,25.43Z"></path><path class="cls-2" d="M200,8.67l.35,1.95A12.34,12.34,0,0,1,208,8.12c6.3,0,10,3.47,10,9.28s-3.58,9.67-10.22,9.67a13.5,13.5,0,0,1-7.13-1.74v8.52h-5.46V8.67Zm12.49,8.94c0-3.34-2-5.22-5.67-5.22a12,12,0,0,0-6.12,1.47V21.4a12,12,0,0,0,6.12,1.46C210.49,22.86,212.48,21,212.48,17.61Z"></path><path class="cls-2" d="M70.56,1.34h5.85L89.62,26.51H83.33L80.61,21.3H66.25l-2.67,5.21H57.35ZM68.34,17H78.56L73.45,6.76Z"></path><path class="cls-2" d="M107.73,24.74a13.22,13.22,0,0,1-7.26,2.33c-4.63,0-8.8-1.53-8.8-7.89V8.67h5.46v9.81c0,3,1.35,4.42,4.66,4.42a11.25,11.25,0,0,0,5.6-1.5V8.67h5.46V26.51h-4.77Z"></path><path class="cls-2" d="M117.61,17.58c0-5.88,3.69-9.46,10.43-9.46A13.34,13.34,0,0,1,135,9.86V1.34h5.46V26.51h-4.76l-.38-2a11.78,11.78,0,0,1-7.51,2.51C121.33,27.07,117.61,23.52,117.61,17.58ZM135,21.4V13.82a12.08,12.08,0,0,0-6.12-1.46c-3.68,0-5.66,1.88-5.66,5.22s2,5.28,5.66,5.28A12,12,0,0,0,135,21.4Z"></path><rect class="cls-2" x="145.95" y="1.34" width="5.46" height="4.73"></rect><rect class="cls-2" x="145.95" y="8.67" width="5.46" height="17.84"></rect><polygon class="cls-2" points="0.15 33.84 17.72 0.79 53.18 0.79 35.61 33.84 0.15 33.84"></polygon></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-02ab3f2e-9065-406b-b3a2-96f948fc1e4e" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3d56eca2-3609-4ab9-a4bc-ffc6d9c94e2a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3cc43eeb-ac3a-4285-9c6d-681d610de30a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path id="uuid-b98eb56c-eb39-485f-8e3f-36f1bc8fa622" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-887f53b2-a25f-4c1a-b207-be4bc0e6cad6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path id="uuid-2c6229e3-766a-4640-9e66-076dd5bbca77" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bb9d6aae-5c61-449e-a3f7-9dc3eca29a10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-78d414ea-ca1f-4f07-a6dd-815839547cb5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-da9a622c-841b-4217-8357-91e7b2ea0fa7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-eab43470-9ed9-4ef3-88a5-73d45fc6a05a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-441d88e6-447f-439d-bb01-ecd138048170" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon id="uuid-04405ac0-17bc-4ab9-8f1b-d9c06ed1387e" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2ec85d2-1c31-42a4-badb-6dc585c3e159" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon id="uuid-140489d1-1cc4-4640-9981-d5f2efde7a30" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-6be63f45-3fa3-45ba-97e7-17573e0bd3f7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bcf865bd-1403-4135-bc2a-39f0ea8693ef" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcc2e61f-be44-4609-aa28-1b6186c8cb58" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815" style="fill:#fff;"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z" style="fill:#fff;"/><polygon id="uuid-4eb1f8b9-3e2f-414b-927c-5027877a6367" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcb70d1d-da3e-4814-a4c9-ff91d6217f07" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z"/><polygon id="uuid-76d49135-5520-455b-8e33-d0d14e5c227c" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+213
View File
@@ -0,0 +1,213 @@
/**
* Ein Zugang zu allen Daten für die gesamte Oberfläche.
*
* Hält die vier Datentöpfe (Profil, Fahrten, Tankvorgänge, Fahrzeugstatus),
* lauscht über die WebSocket-Verbindung auf Änderungen und reicht den
* Verbindungszustand sowie die Warteschlange nach außen. Die Screens kennen
* weder REST noch WebSocket — sie lesen aus diesem Kontext.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import {
ApiFehler,
DataMetricApi,
ENTITAETEN,
type Fahrt,
type Fahrzeugstatus,
type Profil,
type Tankvorgang,
type Verbindungszustand,
type WartenderAuftrag,
} from "../api"
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
export interface DatenWert {
bereit: boolean
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
ladefehler: string | null
verbindung: Verbindungszustand
warteschlange: readonly WartenderAuftrag[]
/** Zeitpunkt der letzten Statusmeldung — treibt die Veraltet-Anzeige. */
statusStand: string | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
fahrten: readonly Fahrt[]
tankvorgaenge: readonly Tankvorgang[]
rohprofil: Profil | null
api: DataMetricApi
neuLaden: () => Promise<void>
jetztAktualisieren: () => Promise<void>
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
profilSpeichern: (aenderung: {
einstellungen?: Einstellungen
fahrzeug?: Fahrzeug
}) => Promise<void>
}
const Kontext = createContext<DatenWert | null>(null)
export function useDaten(): DatenWert {
const wert = useContext(Kontext)
if (!wert) throw new Error("useDaten außerhalb von <DatenAnbieter> verwendet")
return wert
}
export function DatenAnbieter({
api,
children,
}: {
api: DataMetricApi
children: ReactNode
}) {
const [rohprofil, setzeRohprofil] = useState<Profil | null>(null)
const [status, setzeStatus] = useState<Fahrzeugstatus>({})
const [statusStand, setzeStatusStand] = useState<string | null>(null)
const [fahrten, setzeFahrten] = useState<readonly Fahrt[]>([])
const [tankvorgaenge, setzeTankvorgaenge] = useState<readonly Tankvorgang[]>([])
const [bereit, setzeBereit] = useState(false)
const [ladefehler, setzeLadefehler] = useState<string | null>(null)
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
// wenn zwischendurch ein Push hereinkam.
const rohprofilRef = useRef<Profil | null>(null)
rohprofilRef.current = rohprofil
const neuLaden = useCallback(async () => {
try {
const [p, f, t, s] = await Promise.all([
api.profilLesen(),
api.fahrtenLesen(),
api.tankvorgaengeLesen(),
api.rest.zustandLesen<{ daten: Fahrzeugstatus }>(ENTITAETEN.fahrzeugstatus),
])
setzeRohprofil(p)
setzeFahrten(f ?? [])
setzeTankvorgaenge(t ?? [])
setzeStatus(s.attributes.daten ?? {})
setzeStatusStand(s.last_updated)
setzeLadefehler(null)
setzeBereit(true)
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem
if (!netzproblem || !bereit) {
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
}
}
}, [api, bereit])
// Erstladung plus Live-Verbindung.
useEffect(() => {
void neuLaden()
void api.warteschlange.laden()
const abVerbindung = api.live.aufVerbindung((zustand) => {
setzeVerbindung(zustand)
// Nach einem Wiederverbinden kann während der Trennung etwas passiert
// sein, das kein Ereignis mehr ausgelöst hat.
if (zustand === "verbunden") void neuLaden()
})
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
if (!new_state) return
const daten = (new_state.attributes as { daten?: unknown }).daten
switch (entity_id) {
case ENTITAETEN.profil:
setzeRohprofil((daten ?? null) as Profil | null)
break
case ENTITAETEN.fahrten:
setzeFahrten((daten ?? []) as Fahrt[])
break
case ENTITAETEN.tankvorgaenge:
setzeTankvorgaenge((daten ?? []) as Tankvorgang[])
break
case ENTITAETEN.fahrzeugstatus:
setzeStatus((daten ?? {}) as Fahrzeugstatus)
setzeStatusStand(new_state.last_updated)
break
default:
break
}
})
const abWarteschlange = api.warteschlange.aufAenderung(setzeWarteschlange)
void api.live.starten()
return () => {
abVerbindung()
abZustand()
abWarteschlange()
api.live.beenden()
}
// Absichtlich nur an `api` gebunden: neuLaden ändert sich mit `bereit`,
// was hier sonst die Verbindung bei jeder Erstladung neu aufbauen würde.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api])
const einstellungen = useMemo(
() => (rohprofil ? profilZuEinstellungen(rohprofil) : null),
[rohprofil],
)
const fahrzeug = useMemo(
() => (rohprofil ? profilZuFahrzeug(rohprofil, status) : null),
[rohprofil, status],
)
const profilSpeichern = useCallback(
async (aenderung: { einstellungen?: Einstellungen; fahrzeug?: Fahrzeug }) => {
const roh = rohprofilRef.current
if (!roh) throw new Error("Kein Profil geladen — Speichern nicht möglich.")
const neu = zusammenfuehren(
roh,
aenderung.einstellungen ?? profilZuEinstellungen(roh),
aenderung.fahrzeug ?? profilZuFahrzeug(roh, status),
)
// Sofort lokal übernehmen, damit die Oberfläche nicht auf den
// Rückweg über das Backend warten muss (die Warteschlange kann im
// Funkloch beliebig lange brauchen).
setzeRohprofil(neu)
await api.profilSchreiben(neu)
},
[api, status],
)
const jetztAktualisieren = useCallback(async () => {
await api.jetztAktualisieren()
await neuLaden()
}, [api, neuLaden])
const wert: DatenWert = {
bereit,
ladefehler,
verbindung,
warteschlange,
statusStand,
einstellungen,
fahrzeug,
fahrten,
tankvorgaenge,
rohprofil,
api,
neuLaden,
jetztAktualisieren,
profilSpeichern,
}
return <Kontext.Provider value={wert}>{children}</Kontext.Provider>
}
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest"
import type { Profil } from "../api"
import { isoTag } from "../format"
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
function tagVersetzt(tage: number): string {
const d = new Date()
d.setDate(d.getDate() + tage)
return isoTag(d)
}
function beispielprofil(aenderung: Partial<Profil> = {}): Profil {
return {
fahrzeug: {
fin: "WUAZZZF48PA902804",
titel: "RS 4 Avant",
zusatz: "competition",
untertitel: "quattro · 331 kW",
kennzeichen: "M AB 1234",
tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
},
einstellungen: {
uebersichtsbild: "seitenansicht.webp",
fahrten_pausenzeit_min: 15,
smartdeal: { aktiv: true, laeuft_ab: null },
oelwechsel_intervall: { modus: "hersteller", hersteller_km: 30000, hersteller_monate: 24 },
},
reifen: {
aktiv: "sommer",
anzugsmoment_nm: 140,
saetze: { sommer: { km: 12000, marke: "Michelin" }, winter: { km: 8000 } },
},
steuer: { betrag_eur: 320 },
...aenderung,
}
}
describe("profilZuEinstellungen", () => {
it("liest die Fahrzeug- und Einstellungsfelder", () => {
const e = profilZuEinstellungen(beispielprofil())
expect(e.fahrzeugtitel).toBe("RS 4 Avant")
expect(e.kennzeichen).toBe("M AB 1234")
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
})
it("fällt auf sinnvolle Werte zurück, wenn Abschnitte fehlen", () => {
const e = profilZuEinstellungen({})
expect(e.tankvolumen).toBe(58)
expect(e.pauseMin).toBe(15)
expect(e.backupIntervall).toBe("aus")
})
})
describe("SmartDeal-Gültigkeit", () => {
it("gilt ohne Ablaufdatum, solange der Schalter an ist", () => {
const f = profilZuFahrzeug(beispielprofil(), {})
expect(f.smartdeal.aktiv).toBe(true)
})
it("gilt am Ablauftag noch — das Datum ist einschließlich", () => {
const profil = beispielprofil()
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
aktiv: true,
laeuft_ab: isoTag(),
}
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(true)
})
it("gilt am Folgetag nicht mehr", () => {
const profil = beispielprofil()
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
aktiv: true,
laeuft_ab: tagVersetzt(-1),
}
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false)
})
it("bleibt aus, wenn der Schalter aus ist — auch bei gültigem Datum", () => {
const profil = beispielprofil()
;(profil.einstellungen as Record<string, unknown>)["smartdeal"] = {
aktiv: false,
laeuft_ab: tagVersetzt(30),
}
expect(profilZuFahrzeug(profil, {}).smartdeal.aktiv).toBe(false)
})
})
describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
it("übernimmt die Statuswerte unter den Namen des Backends", () => {
const f = profilZuFahrzeug(beispielprofil(), {
km: 48250,
tankprozent: 62,
reichweite_km: 385,
gesichert: true,
sicherheitscheck: [{ label: "Tür vorne links", ok: true }],
})
expect(f.odo).toBe(48250)
expect(f.odoBekannt).toBe(true)
expect(f.tankPct).toBe(62)
expect(f.reichweite).toBe(385)
expect(f.gesichert).toBe(true)
expect(f.sicherheitscheck).toHaveLength(1)
})
it("unterscheidet unbekannten Kilometerstand von null", () => {
const f = profilZuFahrzeug(beispielprofil(), {})
expect(f.odo).toBe(0)
expect(f.odoBekannt).toBe(false)
})
it("übersetzt den aktiven Reifensatz in die Anzeigeform", () => {
const profil = beispielprofil()
;(profil.reifen as Record<string, unknown>)["aktiv"] = "winter"
expect(profilZuFahrzeug(profil, {}).reifen.aktiv).toBe("Winter")
})
})
describe("zusammenfuehren", () => {
it("schreibt geänderte Einstellungen zurück, ohne Unbekanntes zu verlieren", () => {
const roh = beispielprofil({ technik: [{ gruppe: "Motor", posten: [["Hubraum", "2.9 V6"]] }] })
const einstellungen = profilZuEinstellungen(roh)
const fahrzeug = profilZuFahrzeug(roh, {})
einstellungen.kennzeichen = "M XY 9999"
const neu = zusammenfuehren(roh, einstellungen, fahrzeug)
expect((neu.fahrzeug as Record<string, unknown>)["kennzeichen"]).toBe("M XY 9999")
// Abschnitte, die die Oberfläche nicht anfasst, bleiben unverändert.
expect(neu.technik).toEqual([{ gruppe: "Motor", posten: [["Hubraum", "2.9 V6"]] }])
})
it("schreibt den Reifen-Kilometerstand nie mit", () => {
// Das Backend (reifenzaehler.py) schreibt km bei jeder Änderung des
// Kilometerstands fort. Ein im Browser gehaltener älterer Stand darf das
// nicht überschreiben.
const roh = beispielprofil()
const fahrzeug = profilZuFahrzeug(roh, {})
fahrzeug.reifen.sommer.km = 1
fahrzeug.reifen.sommer.marke = "Continental"
const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug)
const saetze = (neu.reifen as Record<string, unknown>)["saetze"] as Record<
string,
Record<string, unknown>
>
expect(saetze["sommer"]?.["km"]).toBe(12000)
expect(saetze["sommer"]?.["marke"]).toBe("Continental")
})
it("macht aus einem leeren Steuerdatum null statt einer leeren Zeichenkette", () => {
const roh = beispielprofil()
const fahrzeug = profilZuFahrzeug(roh, {})
const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug)
expect((neu.steuer as Record<string, unknown>)["faellig"]).toBeNull()
})
})
+241
View File
@@ -0,0 +1,241 @@
/**
* Übersetzt das Fahrzeugprofil des Backends in die Form, mit der die
* Oberfläche arbeitet — und wieder zurück.
*
* Aus dem alten Panel portiert (`profilZuConfig`/`profilZuCar`/
* `configCarZuProfil`). Die Rechenregeln sind unverändert übernommen; nur die
* Typen sind neu, weil das Panel ohne auskam.
*/
import type { Fahrzeugstatus, Profil, SicherheitsPunkt } from "../api"
import { isoTag } from "../format"
export interface Einstellungen {
fahrzeugtitel: string
fahrzeugzusatz: string
fahrzeuguntertitel: string
kennzeichen: string
startbild: string
tankvolumen: number
nachtVon: string
nachtBis: string
pauseMin: number
markenname: string
wlanName: string
backupIntervall: string
letztesBackup: string | null
ausfuehrung: string
}
export interface Reifensatz {
km?: number | null
groesse?: string | null
marke?: string | null
modell?: string | null
dot?: string | null
profiltiefe_mm?: number | null
[weitere: string]: unknown
}
export interface Fahrzeug {
fin: string
details: string
erstzulassung: string
hu: string
odo: number
odoBekannt: boolean
tankPct: number
reichweite: number | null
batteriespannung: number | null
gesichert: boolean | null
sicherheitscheck: SicherheitsPunkt[]
oelwechselFaelligTs: string | null
oelwechselFaelligKm: number | null
inspektionFaelligTs: string | null
inspektionFaelligKm: number | null
smartdeal: { aktiv: boolean; laeuftAb: string | null }
oel: {
modus: string
km: number | null
monate: number | null
herstellerKm: number | null
herstellerMonate: number | null
}
versicherung: Record<string, unknown>
steuer: Record<string, unknown> & { faellig: string }
reifen: {
aktiv: "Sommer" | "Winter"
anzugsmoment: number | null
wechsel: Record<string, unknown>
sommer: Reifensatz
winter: Reifensatz
}
service: Record<string, unknown>
technik: Record<string, unknown>
ausstattung: unknown
}
/**
* Liest einen Abschnitt des Profils **als Kopie**.
*
* Die Kopie ist der Punkt: Ohne sie bekämen die Screens lebende Verweise ins
* Rohprofil, und eine Bearbeitung im Formular würde den Rohstand still
* mitverändern. Damit wäre er als Vergleichsbasis wertlos — und die Zusage,
* den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu
* überschreiben, ließe sich nicht mehr halten.
*/
function abschnitt(profil: Profil, name: string): Record<string, unknown> {
const wert = profil[name]
if (!wert || typeof wert !== "object") return {}
return structuredClone(wert) as Record<string, unknown>
}
function text(quelle: Record<string, unknown>, feld: string, ersatz = ""): string {
const wert = quelle[feld]
return typeof wert === "string" ? wert : ersatz
}
function zahl(quelle: Record<string, unknown>, feld: string): number | null {
const wert = quelle[feld]
return typeof wert === "number" ? wert : null
}
export function profilZuEinstellungen(profil: Profil): Einstellungen {
const fahrzeug = abschnitt(profil, "fahrzeug")
const einst = abschnitt(profil, "einstellungen")
return {
fahrzeugtitel: text(fahrzeug, "titel"),
fahrzeugzusatz: text(fahrzeug, "zusatz"),
fahrzeuguntertitel: text(fahrzeug, "untertitel"),
kennzeichen: text(fahrzeug, "kennzeichen"),
startbild: text(einst, "uebersichtsbild", "seitenansicht.webp"),
tankvolumen: zahl(fahrzeug, "tankvolumen_liter") ?? 58,
nachtVon: text(einst, "nacht_von", "22:00"),
nachtBis: text(einst, "nacht_bis", "06:00"),
pauseMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
markenname: text(einst, "kraftstoffanbieter", "Shell"),
wlanName: text(fahrzeug, "wlan_name"),
backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null,
ausfuehrung: text(fahrzeug, "ausfuehrung"),
}
}
export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrzeug {
const fahrzeug = abschnitt(profil, "fahrzeug")
const einst = abschnitt(profil, "einstellungen")
// Bereits eine Kopie (siehe abschnitt()), darf also gefahrlos weitergereicht
// und von den Formularen bearbeitet werden.
const reifen = abschnitt(profil, "reifen")
const saetze = (reifen["saetze"] ?? {}) as Record<string, Reifensatz>
const smartdeal = (einst["smartdeal"] ?? {}) as Record<string, unknown>
const oel = (einst["oelwechsel_intervall"] ?? {}) as Record<string, unknown>
const steuer = abschnitt(profil, "steuer")
const laeuftAb = typeof smartdeal["laeuft_ab"] === "string" ? (smartdeal["laeuft_ab"] as string) : null
return {
fin: text(fahrzeug, "fin"),
details: text(fahrzeug, "details"),
erstzulassung: text(fahrzeug, "erstzulassung"),
hu: text(fahrzeug, "hauptuntersuchung_faellig"),
odo: status.km ?? 0,
odoBekannt: status.km !== null && status.km !== undefined,
tankPct: status.tankprozent ?? 0,
reichweite: status.reichweite_km ?? null,
batteriespannung: status.batteriespannung ?? null,
gesichert: status.gesichert ?? null,
sicherheitscheck: status.sicherheitscheck ?? [],
oelwechselFaelligTs: status.oelwechsel_faellig_ts ?? null,
oelwechselFaelligKm: status.oelwechsel_faellig_km ?? null,
inspektionFaelligTs: status.inspektion_faellig_ts ?? null,
inspektionFaelligKm: status.inspektion_faellig_km ?? null,
// „Gültig bis" ist einschließlich: der Rabatt gilt noch am Stichtag selbst,
// erst am Folgetag schaltet er ab. Unverändert aus dem alten Panel.
smartdeal: {
aktiv: smartdeal["aktiv"] === true && (!laeuftAb || laeuftAb >= isoTag()),
laeuftAb,
},
oel: {
modus: text(oel, "modus", "hersteller"),
km: zahl(oel, "km"),
monate: zahl(oel, "monate"),
herstellerKm: zahl(oel, "hersteller_km"),
herstellerMonate: zahl(oel, "hersteller_monate"),
},
versicherung: abschnitt(profil, "versicherung"),
steuer: { ...steuer, faellig: text(steuer, "faellig") },
reifen: {
aktiv: reifen["aktiv"] === "winter" ? "Winter" : "Sommer",
anzugsmoment: zahl(reifen, "anzugsmoment_nm"),
wechsel: (reifen["wechsel"] ?? {}) as Record<string, unknown>,
sommer: saetze["sommer"] ?? {},
winter: saetze["winter"] ?? {},
},
service: abschnitt(profil, "service"),
technik: abschnitt(profil, "technik"),
// Als Einzige eine Liste statt eines Objekts, deshalb eigens kopiert.
ausstattung: profil["ausstattung"] ? structuredClone(profil["ausstattung"]) : [],
}
}
/**
* Rückrichtung für `audi_dashboard_profil_schreiben`. Das Backend kennt keinen
* Dienst für einzelne Felder — es wird immer das ganze Profil geschrieben,
* deshalb wird der zuletzt gelesene Rohstand kopiert und nur das Geänderte
* überschrieben.
*/
export function zusammenfuehren(
rohprofil: Profil,
einstellungen: Einstellungen,
fahrzeug: Fahrzeug,
): Profil {
const p = structuredClone(rohprofil) as Profil
const f = (p["fahrzeug"] ??= {}) as Record<string, unknown>
const e = (p["einstellungen"] ??= {}) as Record<string, unknown>
const r = (p["reifen"] ??= {}) as Record<string, unknown>
f["titel"] = einstellungen.fahrzeugtitel
f["zusatz"] = einstellungen.fahrzeugzusatz
f["untertitel"] = einstellungen.fahrzeuguntertitel
f["kennzeichen"] = einstellungen.kennzeichen
f["wlan_name"] = einstellungen.wlanName
f["ausfuehrung"] = einstellungen.ausfuehrung
f["tankvolumen_liter"] = einstellungen.tankvolumen
f["fin"] = fahrzeug.fin
f["erstzulassung"] = fahrzeug.erstzulassung
f["hauptuntersuchung_faellig"] = fahrzeug.hu
e["uebersichtsbild"] = einstellungen.startbild
e["fahrten_pausenzeit_min"] = einstellungen.pauseMin
e["backup_intervall"] = einstellungen.backupIntervall
e["nacht_von"] = einstellungen.nachtVon
e["nacht_bis"] = einstellungen.nachtBis
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
e["oelwechsel_intervall"] = {
modus: fahrzeug.oel.modus,
km: fahrzeug.oel.km,
monate: fahrzeug.oel.monate,
hersteller_km: fahrzeug.oel.herstellerKm,
hersteller_monate: fahrzeug.oel.herstellerMonate,
}
p["versicherung"] = fahrzeug.versicherung
p["steuer"] = { ...fahrzeug.steuer, faellig: fahrzeug.steuer.faellig || null }
p["service"] = fahrzeug.service
r["aktiv"] = fahrzeug.reifen.aktiv.toLowerCase()
r["anzugsmoment_nm"] = fahrzeug.reifen.anzugsmoment
r["wechsel"] = fahrzeug.reifen.wechsel
const saetze = (r["saetze"] ??= {}) as Record<string, Reifensatz>
for (const name of ["sommer", "winter"] as const) {
// km bewusst NICHT mitschreiben: das Backend (reifenzaehler.py) schreibt
// den Wert bei jeder Kilometerstandsänderung fort. Ein im Browser
// gehaltener älterer Stand würde eine zwischenzeitliche Fortschreibung
// überschreiben.
const { km: _km, ...rest } = fahrzeug.reifen[name]
saetze[name] = { ...(saetze[name] ?? {}), ...rest }
}
return p
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest"
import { letzterOelwechsel, oelwechselPrognose } from "./service"
import type { Fahrzeug } from "./profilAdapter"
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
return {
fin: "",
details: "",
erstzulassung: "",
hu: "",
odo,
odoBekannt: true,
tankPct: 0,
reichweite: null,
batteriespannung: null,
gesichert: null,
sicherheitscheck: [],
oelwechselFaelligTs: null,
oelwechselFaelligKm: null,
inspektionFaelligTs: null,
inspektionFaelligKm: null,
smartdeal: { aktiv: false, laeuftAb: null },
oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 },
versicherung: {},
steuer: { faellig: "" },
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} },
service: {},
technik: {},
ausstattung: [],
}
}
const BUCH = [
{ datum: "2026-02-10", km: 40000, art: "Ölwechsel" },
{ datum: "2025-03-05", km: 25000, art: "Inspektion" },
]
describe("letzterOelwechsel", () => {
it("findet den jüngsten Ölwechsel und ignoriert andere Arten", () => {
expect(letzterOelwechsel(BUCH)?.datum).toBe("2026-02-10")
})
it("erkennt auch die Schreibweise ohne Umlaut", () => {
expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Oelwechsel" }])).not.toBeNull()
})
it("liefert null, wenn nie einer eingetragen wurde", () => {
expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Inspektion" }])).toBeNull()
})
})
describe("oelwechselPrognose", () => {
const jetzt = new Date("2026-08-11T12:00:00")
it("rechnet ab dem letzten Ölwechsel, nicht ab dem ersten Buchungseintrag", () => {
// Seit 10.02. sind 182 Tage vergangen, 6000 km gefahren -> rund 33 km/Tag.
// Ziel ist 40000 + 30000 = 70000, offen sind also 24000 km.
const p = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt)
expect(p).not.toBeNull()
expect(p!.restKm).toBe(24000)
expect(p!.kmProTag).toBeGreaterThan(30)
expect(p!.kmProTag).toBeLessThan(36)
})
it("deckelt auf das Zeitlimit, wenn wenig gefahren wurde", () => {
// Nur 100 km seit dem Wechsel: die Kilometerprognose läge Jahre entfernt,
// das Zeitlimit von 24 Monaten greift vorher.
const p = oelwechselPrognose(fahrzeug(40100), BUCH, jetzt)
expect(p!.durchZeitlimit).toBe(true)
expect(p!.datum).toBe("2028-02-10")
})
it("meldet Fälligkeit sofort, wenn das Intervall überschritten ist", () => {
const p = oelwechselPrognose(fahrzeug(75000), BUCH, jetzt)
expect(p!.restKm).toBe(0)
expect(p!.datum).toBe("2026-08-11")
})
it("nutzt bei eigenem Intervall den kleineren der beiden Werte", () => {
// modus "eigen": min(15000, 30000) = 15000 -> Ziel 55000, offen 9000.
const p = oelwechselPrognose(fahrzeug(46000, "eigen"), BUCH, jetzt)
expect(p!.restKm).toBe(9000)
})
it("liefert nichts ohne bekannten Kilometerstand", () => {
const ohne = { ...fahrzeug(0), odoBekannt: false }
expect(oelwechselPrognose(ohne, BUCH, jetzt)).toBeNull()
})
it("liefert nichts, wenn nie ein Ölwechsel eingetragen wurde", () => {
expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull()
})
})
+96
View File
@@ -0,0 +1,96 @@
/**
* Eigene Ölwechsel-Prognose. Portiert aus `oelwechselPrognose()` im alten
* Panel.
*
* Die Rechnung beginnt bewusst neu ab dem letzten tatsächlich durchgeführten
* Ölwechsel — der allgemeine Langzeitdurchschnitt seit dem allerersten
* Servicebucheintrag wäre dafür zu träge. Aus km und Tagen seit dem letzten
* Wechsel ergibt sich die aktuelle Fahrleistung pro Tag; hochgerechnet auf die
* verbleibenden km bis zum eingestellten Intervall ergibt das ein Datum,
* gedeckelt auf spätestens das Zeitlimit des Intervalls.
*/
import type { Fahrzeug } from "./profilAdapter"
const TAG_MS = 86_400_000
export interface Oelprognose {
/** ISO-Tagesdatum. */
datum: string
restKm: number
kmProTag: number
/** Die Zeitgrenze hat gegriffen, nicht die Kilometergrenze. */
durchZeitlimit: boolean
}
interface Buchhaltung {
datum?: string
km?: number
art?: string
}
function monatePlus(isoDatum: string, monate: number): string {
const d = new Date(isoDatum)
if (Number.isNaN(d.getTime())) return isoDatum
d.setMonth(d.getMonth() + monate)
return d.toISOString().slice(0, 10)
}
/** Der jüngste Servicebucheintrag, dessen Art auf einen Ölwechsel hindeutet. */
export function letzterOelwechsel(buch: readonly Buchhaltung[]): Buchhaltung | null {
const treffer = buch
.filter((e) => typeof e.art === "string" && /öl|oel/i.test(e.art) && e.datum && e.km != null)
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null
}
export function oelwechselPrognose(
fahrzeug: Fahrzeug,
buch: readonly Buchhaltung[],
jetzt = new Date(),
): Oelprognose | null {
if (!fahrzeug.odoBekannt) return null
const letzter = letzterOelwechsel(buch)
if (!letzter?.datum || letzter.km == null) return null
const oel = fahrzeug.oel
const intervallKm =
oel.modus === "hersteller"
? (oel.herstellerKm ?? 0)
: Math.min(oel.km ?? Infinity, oel.herstellerKm ?? Infinity)
const intervallMonate =
oel.modus === "hersteller"
? (oel.herstellerMonate ?? 0)
: Math.min(oel.monate ?? Infinity, oel.herstellerMonate ?? Infinity)
if (!Number.isFinite(intervallKm) || intervallKm <= 0) return null
const zielKm = letzter.km + intervallKm
const restKm = Math.max(0, zielKm - fahrzeug.odo)
const kappDatum = monatePlus(letzter.datum, Number.isFinite(intervallMonate) ? intervallMonate : 24)
if (restKm <= 0) {
return { datum: jetzt.toISOString().slice(0, 10), restKm: 0, kmProTag: 0, durchZeitlimit: false }
}
const tageSeit = (jetzt.getTime() - new Date(letzter.datum).getTime()) / TAG_MS
const kmSeit = fahrzeug.odo - letzter.km
// Ohne belastbare Fahrleistung (direkt nach dem Wechsel) gibt es keine
// seriöse Kilometerprognose — dann gilt das Zeitlimit.
if (tageSeit <= 0 || kmSeit <= 0) {
return { datum: kappDatum, restKm, kmProTag: 0, durchZeitlimit: true }
}
const kmProTag = kmSeit / tageSeit
const tageBisZiel = restKm / kmProTag
const ausKm = new Date(jetzt.getTime() + tageBisZiel * TAG_MS).toISOString().slice(0, 10)
const durchZeitlimit = kappDatum < ausKm
return {
datum: durchZeitlimit ? kappDatum : ausKm,
restKm,
kmProTag,
durchZeitlimit,
}
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest"
import type { Fahrt, Tankvorgang } from "../api"
import {
fahrtenSeit,
istNachtZeit,
jahresauswertung,
langzeitverbrauch,
nachJahrUndMonat,
tankSeit,
zeitraumStart,
} from "./statistik"
const JETZT = new Date("2026-08-11T12:00:00")
function fahrt(teil: Partial<Fahrt> & { trip_id: string; ts_start: string }): Fahrt {
return {
ts_end: teil.ts_start,
duration_s: 1800,
distance_km: 10,
km_quelle: "odometer",
odo_start: null,
odo_end: null,
art: "privat",
source: "ha",
status: "vollständig",
...teil,
}
}
function tankung(teil: Partial<Tankvorgang> & { tank_id: string; ts: string }): Tankvorgang {
return {
liters: 40,
fuel_total_eur: 70,
price_per_l: 1.75,
source: "manual",
status: "vollständig",
...teil,
}
}
/** Ortszeit-Datum. toISOString() waere UTC und kippt in Zeitzonen oestlich
von Greenwich auf den Vortag - der Code rechnet aber bewusst lokal. */
function alsTag(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
describe("zeitraumStart", () => {
it("beginnt das Jahr am 1. Januar", () => {
expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01")
})
it("beginnt den Monat am Ersten", () => {
expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01")
})
it("beginnt die Woche am Montag, nicht am Sonntag", () => {
// Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08.
const start = zeitraumStart("woche", JETZT)
expect(start.getDay()).toBe(1)
expect(start.getDate()).toBe(10)
})
it("beginnt den Tag um Mitternacht", () => {
const start = zeitraumStart("tag", JETZT)
expect(start.getHours()).toBe(0)
expect(start.getDate()).toBe(11)
})
})
describe("istNachtZeit", () => {
it("erkennt eine Spanne über Mitternacht", () => {
expect(istNachtZeit("2026-08-11T23:30:00", "22:00", "06:00")).toBe(true)
expect(istNachtZeit("2026-08-11T03:00:00", "22:00", "06:00")).toBe(true)
expect(istNachtZeit("2026-08-11T12:00:00", "22:00", "06:00")).toBe(false)
})
it("erkennt eine Spanne innerhalb eines Tages", () => {
expect(istNachtZeit("2026-08-11T13:00:00", "12:00", "14:00")).toBe(true)
expect(istNachtZeit("2026-08-11T15:00:00", "12:00", "14:00")).toBe(false)
})
it("schließt den Beginn ein und das Ende aus", () => {
expect(istNachtZeit("2026-08-11T22:00:00", "22:00", "06:00")).toBe(true)
expect(istNachtZeit("2026-08-11T06:00:00", "22:00", "06:00")).toBe(false)
})
})
describe("fahrtenSeit und tankSeit", () => {
const fahrten = [
fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }),
fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }),
fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }),
fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }),
fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }),
]
it("filtert je Zeitraum", () => {
expect(fahrtenSeit(fahrten, "tag", JETZT).map((f) => f.trip_id)).toEqual(["heute"])
expect(fahrtenSeit(fahrten, "woche", JETZT).map((f) => f.trip_id)).toEqual([
"heute",
"diesewoche",
])
expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3)
})
it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => {
expect(fahrtenSeit(fahrten, "tag", JETZT).some((f) => f.trip_id === "ohnestrecke")).toBe(false)
})
it("lässt Tankungen ohne Menge außen vor", () => {
const tanks = [
tankung({ tank_id: "voll", ts: "2026-08-11T09:00:00" }),
tankung({ tank_id: "offen", ts: "2026-08-11T10:00:00", liters: null }),
]
expect(tankSeit(tanks, "tag", JETZT).map((t) => t.tank_id)).toEqual(["voll"])
})
})
describe("langzeitverbrauch", () => {
it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => {
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.km).toBe(600)
expect(ergebnis.liter).toBe(100)
// 100 l auf 600 km sind 16,7 l/100 km — plausibel.
expect(ergebnis.wert).toBeCloseTo(16.67, 1)
expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen")
})
it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => {
const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })]
const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })]
const ergebnis = langzeitverbrauch(fahrten, tanks)
expect(ergebnis.km).toBe(500)
expect(ergebnis.basis).toBe("erfasste Fahrten")
})
it("meldet unplausible Werte als unbekannt statt als Tatsache", () => {
// 100 l auf 50 km wären 200 l/100 km - unmöglich.
const tanks = [
tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }),
tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40050 }),
]
const ergebnis = langzeitverbrauch([], tanks)
expect(ergebnis.wert).toBeNull()
expect(ergebnis.unplausibel).toBe(true)
})
it("kommt ohne Tankvorgänge zurecht", () => {
const ergebnis = langzeitverbrauch([], [])
expect(ergebnis.wert).toBeNull()
expect(ergebnis.unplausibel).toBe(false)
expect(ergebnis.bis).toBeNull()
})
})
describe("jahresauswertung", () => {
it("teilt die Strecke in Tag und Nacht sowie in Arbeitsweg und privat", () => {
const fahrten = [
fahrt({ trip_id: "tag", ts_start: "2026-08-05T12:00:00", distance_km: 100, art: "arbeitsweg" }),
fahrt({ trip_id: "nacht", ts_start: "2026-08-05T23:00:00", distance_km: 40 }),
]
const a = jahresauswertung(fahrten, [], "22:00", "06:00", JETZT)
expect(a.kmJahr).toBe(140)
expect(a.tagKm).toBe(100)
expect(a.nachtKm).toBe(40)
expect(a.arbeitKm).toBe(100)
expect(a.privatKm).toBe(40)
expect(a.arbeitFahrten).toBe(1)
expect(a.privatFahrten).toBe(1)
})
})
describe("nachJahrUndMonat", () => {
it("gruppiert absteigend, Neuestes zuerst", () => {
const eintraege = [
{ id: "alt", ts: "2025-03-04T10:00:00" },
{ id: "neu", ts: "2026-08-01T10:00:00" },
{ id: "mittel", ts: "2026-02-01T10:00:00" },
]
const gruppen = nachJahrUndMonat(eintraege, (e) => e.ts)
expect(gruppen.map((g) => g.jahr)).toEqual([2026, 2025])
expect(gruppen[0]?.monate.map((m) => m.monat)).toEqual([7, 1])
expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["neu", "mittel"])
})
it("überspringt unlesbare Zeitangaben, statt daran zu scheitern", () => {
const gruppen = nachJahrUndMonat(
[{ id: "kaputt", ts: "keine-zeit" }, { id: "gut", ts: "2026-08-01T10:00:00" }],
(e) => e.ts,
)
expect(gruppen).toHaveLength(1)
expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["gut"])
})
})
+242
View File
@@ -0,0 +1,242 @@
/**
* Auswertung von Fahrten und Tankvorgängen.
*
* Aus dem alten Panel portiert (`periodStart`, `fahrtenSeit`, `tankSeit`,
* `istNachtZeit`, `langzeitverbrauch`). Die Rechenregeln sind bewusst
* unverändert — sie sind über mehrere Runden entstanden und begründet.
*/
import type { Fahrt, Tankvorgang } from "../api"
import { summe } from "../format"
export type Zeitraum = "jahr" | "monat" | "woche" | "tag"
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
jahr: "Jahr",
monat: "Monat",
woche: "Woche",
tag: "Tag",
}
/** Beginn des Zeitraums. Die Woche startet montags, nicht sonntags. */
export function zeitraumStart(art: Zeitraum, jetzt = new Date()): Date {
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1)
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1)
const d = new Date(jetzt)
d.setHours(0, 0, 0, 0)
if (art === "woche") d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
return d
}
export function fahrtenSeit(fahrten: readonly Fahrt[], art: Zeitraum, jetzt = new Date()): Fahrt[] {
const start = zeitraumStart(art, jetzt)
return fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) >= start)
}
export function tankSeit(
tankvorgaenge: readonly Tankvorgang[],
art: Zeitraum,
jetzt = new Date(),
): Tankvorgang[] {
const start = zeitraumStart(art, jetzt)
return tankvorgaenge.filter((t) => t.liters != null && new Date(t.ts) >= start)
}
/**
* Liegt der Zeitpunkt in der Nachtspanne? Die Spanne darf über Mitternacht
* gehen (22:00 bis 06:00) — dann gilt sie außerhalb statt innerhalb.
*/
export function istNachtZeit(zeitpunkt: string | Date, von: string, bis: string): boolean {
const d = zeitpunkt instanceof Date ? zeitpunkt : new Date(zeitpunkt)
const uhrzeit = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
return von > bis ? uhrzeit >= von || uhrzeit < bis : uhrzeit >= von && uhrzeit < bis
}
export interface Langzeitverbrauch {
/** null, wenn kein plausibler Wert bestimmbar ist. */
wert: number | null
km: number
liter: number
bis: Date | null
basis: "Kilometerstand an den Tankvorgängen" | "erfasste Fahrten" | ""
/** Ein Wert kam heraus, lag aber außerhalb des Plausibilitätsfensters. */
unplausibel: boolean
}
/**
* Alle je getankten Liter gegen die Strecke bis zum letzten Tankstop.
*
* Bewusst nicht „Liter des Zeitraums geteilt durch km des Zeitraums": das ergab
* unmögliche Werte, sobald eine Fahrt fehlte oder ein Tankvorgang noch
* unvollständig war. Werte außerhalb von 3 bis 30 l/100 km gelten als
* unbekannt statt als Tatsache.
*/
export function langzeitverbrauch(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
): Langzeitverbrauch {
const tanks = tankvorgaenge
.filter((t) => t.liters != null)
.slice()
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime())
if (tanks.length === 0) {
return { wert: null, km: 0, liter: 0, bis: null, basis: "", unplausibel: false }
}
const letzter = tanks[tanks.length - 1]!
const bis = new Date(letzter.ts)
const liter = summe(tanks, (t) => t.liters ?? 0)
// Bevorzugt der Kilometerstand an den Tankvorgängen selbst — er zählt auch
// Strecke mit, für die keine Fahrt aufgezeichnet wurde.
const mitOdo = tanks.filter((t) => t.odometer_km != null)
let km: number
let basis: Langzeitverbrauch["basis"]
if (mitOdo.length >= 2) {
km = (mitOdo[mitOdo.length - 1]!.odometer_km ?? 0) - (mitOdo[0]!.odometer_km ?? 0)
basis = "Kilometerstand an den Tankvorgängen"
} else {
km = summe(
fahrten.filter((f) => f.distance_km != null && new Date(f.ts_start) <= bis),
(f) => f.distance_km ?? 0,
)
basis = "erfasste Fahrten"
}
const roh = km > 0 ? (liter / km) * 100 : null
const gueltig = roh !== null && roh >= 3 && roh <= 30
return {
wert: gueltig ? roh : null,
km,
liter,
bis,
basis,
unplausibel: roh !== null && !gueltig,
}
}
export interface Jahresauswertung {
jahr: number
kmJahr: number
stundenJahr: number
literJahr: number
kostenJahr: number
tagKm: number
nachtKm: number
arbeitKm: number
privatKm: number
arbeitFahrten: number
privatFahrten: number
jeZeitraum: Record<Zeitraum, { km: number; stunden: number; liter: number; kosten: number }>
langzeit: Langzeitverbrauch
}
export function jahresauswertung(
fahrten: readonly Fahrt[],
tankvorgaenge: readonly Tankvorgang[],
nachtVon: string,
nachtBis: string,
jetzt = new Date(),
): Jahresauswertung {
const jeZeitraum = {} as Jahresauswertung["jeZeitraum"]
for (const art of ZEITRAEUME) {
const f = fahrtenSeit(fahrten, art, jetzt)
const t = tankSeit(tankvorgaenge, art, jetzt)
jeZeitraum[art] = {
km: summe(f, (x) => x.distance_km ?? 0),
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),
}
}
const fJahr = fahrtenSeit(fahrten, "jahr", jetzt)
const kmJahr = jeZeitraum.jahr.km
const tagKm = summe(
fJahr.filter((f) => !istNachtZeit(f.ts_start, nachtVon, nachtBis)),
(f) => f.distance_km ?? 0,
)
const arbeit = fJahr.filter((f) => f.art === "arbeitsweg")
const arbeitKm = summe(arbeit, (f) => f.distance_km ?? 0)
return {
jahr: jetzt.getFullYear(),
kmJahr,
stundenJahr: jeZeitraum.jahr.stunden,
literJahr: jeZeitraum.jahr.liter,
kostenJahr: jeZeitraum.jahr.kosten,
tagKm,
nachtKm: kmJahr - tagKm,
arbeitKm,
privatKm: kmJahr - arbeitKm,
arbeitFahrten: arbeit.length,
privatFahrten: fJahr.length - arbeit.length,
jeZeitraum,
langzeit: langzeitverbrauch(fahrten, tankvorgaenge),
}
}
/* ------------------------------------------------- Gruppierung für Listen */
export interface MonatsGruppe<T> {
/** Format YYYY-MM, damit sich absteigend sortieren lässt. */
schluessel: string
jahr: number
monat: number
eintraege: T[]
}
export interface JahresGruppe<T> {
jahr: number
monate: MonatsGruppe<T>[]
eintraege: T[]
}
/**
* Gruppiert nach Jahr und Monat, beide absteigend — das Neueste zuerst,
* so wie es die Listenansichten von Fahrten und Tankvorgängen zeigen.
*/
export function nachJahrUndMonat<T>(
eintraege: readonly T[],
zeitpunktVon: (eintrag: T) => string,
): JahresGruppe<T>[] {
const jahre = new Map<number, Map<string, MonatsGruppe<T>>>()
const sortiert = eintraege
.slice()
.sort((a, b) => new Date(zeitpunktVon(b)).getTime() - new Date(zeitpunktVon(a)).getTime())
for (const eintrag of sortiert) {
const d = new Date(zeitpunktVon(eintrag))
if (Number.isNaN(d.getTime())) continue
const jahr = d.getFullYear()
const monat = d.getMonth()
const schluessel = `${jahr}-${String(monat + 1).padStart(2, "0")}`
let monate = jahre.get(jahr)
if (!monate) {
monate = new Map()
jahre.set(jahr, monate)
}
let gruppe = monate.get(schluessel)
if (!gruppe) {
gruppe = { schluessel, jahr, monat, eintraege: [] }
monate.set(schluessel, gruppe)
}
gruppe.eintraege.push(eintrag)
}
return [...jahre.entries()]
.sort((a, b) => b[0] - a[0])
.map(([jahr, monate]) => {
const sortierteMonate = [...monate.values()].sort((a, b) => b.monat - a.monat)
return {
jahr,
monate: sortierteMonate,
eintraege: sortierteMonate.flatMap((m) => m.eintraege),
}
})
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest"
import { alsCsv } from "./screens/csv"
import { kalenderDatei } from "./screens/kalender"
import { dauer, de, deOderStrich, eur, isoTag, schnittpreis, summe } from "./format"
describe("Zahlen im deutschen Format", () => {
it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => {
expect(de(48250)).toBe("48.250")
expect(de(1234.5, 1)).toBe("1.234,5")
expect(eur(89.1)).toBe("89,10")
})
it("macht aus fehlenden Werten eine Null, nicht NaN", () => {
expect(de(null)).toBe("0")
expect(de(undefined)).toBe("0")
})
it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => {
expect(deOderStrich(0)).toBe("0")
expect(deOderStrich(null)).toBe("—")
})
})
describe("dauer", () => {
it("zeigt unter einer Stunde nur Minuten", () => {
expect(dauer(2700)).toBe("45 min")
})
it("zeigt darüber Stunden und Minuten zweistellig", () => {
expect(dauer(8040)).toBe("2 h 14 min")
})
it("zeigt einen Strich statt „0 min", () => {
expect(dauer(0)).toBe("—")
expect(dauer(null)).toBe("—")
})
})
describe("schnittpreis", () => {
it("gewichtet nach Menge, nicht nach Anzahl der Tankungen", () => {
// 10 l zu 2,00 € und 90 l zu 1,00 €: der Schnitt muss nahe 1,10 liegen,
// nicht bei 1,50 (dem Mittel der Einzelpreise).
const schnitt = schnittpreis([
{ liters: 10, fuel_total_eur: 20 },
{ liters: 90, fuel_total_eur: 90 },
])
expect(schnitt).toBeCloseTo(1.1, 2)
})
it("liefert 0 statt einer Division durch null", () => {
expect(schnittpreis([])).toBe(0)
expect(schnittpreis([{ liters: 0, fuel_total_eur: 10 }])).toBe(0)
})
})
describe("summe", () => {
it("überspringt fehlende Werte", () => {
expect(summe([{ a: 1 }, { a: null }, { a: 3 }], (e) => e.a ?? 0)).toBe(4)
})
})
describe("isoTag", () => {
it("gibt das lokale Datum aus, nicht das UTC-Datum", () => {
// 23:30 Ortszeit ist in UTC schon der Folgetag — der Tag darf nicht kippen.
const spaet = new Date(2026, 7, 11, 23, 30)
expect(isoTag(spaet)).toBe("2026-08-11")
})
})
describe("CSV-Ausgabe", () => {
it("trennt mit Semikolon und schreibt Dezimalkomma", () => {
const csv = alsCsv(["Strecke", "Ort"], [[42.5, "München"]])
expect(csv).toContain("Strecke;Ort")
expect(csv).toContain("42,5;München")
})
it("beginnt mit einem BOM, damit Excel die Umlaute richtig liest", () => {
expect(alsCsv(["a"], [])[0]).toBe("")
})
it("schützt Felder mit Semikolon oder Anführungszeichen", () => {
const csv = alsCsv(["Text"], [['Er sagte "hallo"; dann ging er']])
expect(csv).toContain('"Er sagte ""hallo""; dann ging er"')
})
})
describe("Kalenderdatei", () => {
it("erzeugt einen ganztägigen Termin mit Erinnerung zwei Tage vorher", () => {
const ics = kalenderDatei("Ölwechsel", "2027-03-14", "laut Prognose")
expect(ics).toContain("BEGIN:VCALENDAR")
expect(ics).toContain("DTSTART;VALUE=DATE:20270314")
expect(ics).toContain("SUMMARY:Ölwechsel")
expect(ics).toContain("TRIGGER:-P2D")
expect(ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true)
})
it("schützt Sonderzeichen im Titel", () => {
expect(kalenderDatei("Service; Öl, Filter", "2027-01-01")).toContain(
"SUMMARY:Service\\; Öl\\, Filter",
)
})
})
+103
View File
@@ -0,0 +1,103 @@
/**
* Zahlen, Geld, Datum und Dauer im deutschen Format. Aus dem alten Panel
* übernommen (`de()`/`eur()`), damit App und Panel identisch formatieren.
*/
export function de(wert: number | null | undefined, nachkomma = 0): string {
return Number(wert ?? 0).toLocaleString("de-DE", {
minimumFractionDigits: nachkomma,
maximumFractionDigits: nachkomma,
})
}
export function eur(wert: number | null | undefined): string {
return de(wert, 2)
}
/** 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)
}
/** Summe über eine Liste. */
export function summe<T>(liste: readonly T[], auswahl: (eintrag: T) => number): number {
return liste.reduce((zwischenstand, eintrag) => zwischenstand + (auswahl(eintrag) || 0), 0)
}
/**
* Durchschnittspreis je Liter — immer mengengewichtet (Summe Kosten geteilt
* durch Summe Liter), nie als Mittel der Einzelpreise. Eine kleine Tankung zu
* hohem Preis darf den Schnitt nicht so stark heben wie eine große.
*/
export function schnittpreis(
liste: readonly { liters?: number | null; fuel_total_eur?: number | null }[],
): number {
const liter = summe(liste, (t) => t.liters ?? 0)
if (liter <= 0) return 0
return summe(liste, (t) => t.fuel_total_eur ?? 0) / liter
}
const DATUM_KURZ = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
})
const UHRZEIT = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" })
const MONAT_JAHR = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" })
function alsDatum(wert: string | number | Date | null | undefined): Date | null {
if (wert === null || wert === undefined || wert === "") return null
const datum = wert instanceof Date ? wert : new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum
}
export function datum(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
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) : "—"
}
export function uhrzeit(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
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` : "—"
}
export function monatJahr(wert: string | number | Date | null | undefined): string {
const d = alsDatum(wert)
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 "—"
const minutenGesamt = Math.round(sekunden / 60)
const stunden = Math.floor(minutenGesamt / 60)
const minuten = minutenGesamt % 60
if (stunden === 0) return `${minuten} min`
return `${stunden} h ${String(minuten).padStart(2, "0")} min`
}
/** 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)
if (!d) return ""
const versatz = d.getTimezoneOffset() * 60_000
return new Date(d.getTime() - versatz).toISOString().slice(0, 10)
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Schalter für Funktionen, die gebaut, aber noch nicht nutzbar sind.
*
* Der Sinn: Die Oberfläche dafür existiert und ist geprüft, sie zeigt aber
* nichts an, solange keine echten Daten fließen. Ein sichtbarer Menüpunkt mit
* lauter Nullen wäre schlechter als gar keiner.
*/
/**
* Live-Ansicht der laufenden Fahrt (Position, Geschwindigkeit, Strecke).
*
* Braucht den FMM003 am CAN-Bus, der die Werte per MQTT nach Home Assistant
* schickt — siehe COMPANION_APP_ARCHITECTURE.md §2b und Phase 13 des
* Umsetzungsplans. **Erst umlegen, wenn die Zuordnungstabelle aus einer echten
* MQTT-Nachricht gebaut ist**, nicht vorher.
*/
export const LIVE_VERFUEGBAR = false
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react"
/**
* Ab dieser Breite wechselt die App vom Handy- aufs Flächenlayout: Navigation
* wandert von unten nach links, Inhalte dürfen mehrspaltig werden. Der Wert
* stammt aus dem Design-Auftrag ("ab ca. 1000px").
*/
export const BREITER_BILDSCHIRM = "(min-width: 1000px)"
export function useBreiterBildschirm(): boolean {
const [breit, setzeBreit] = useState<boolean>(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false
return window.matchMedia(BREITER_BILDSCHIRM).matches
})
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
const abfrage = window.matchMedia(BREITER_BILDSCHIRM)
const beiAenderung = (ereignis: MediaQueryListEvent) => setzeBreit(ereignis.matches)
abfrage.addEventListener("change", beiAenderung)
setzeBreit(abfrage.matches)
return () => abfrage.removeEventListener("change", beiAenderung)
}, [])
return breit
}
+21
View File
@@ -0,0 +1,21 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "@audi-dash/ui/styles.css"
import "./stile/grundlage.css"
import "./stile/audi-schrift.css"
import { nativeAblageEinrichten } from "./api"
import { App } from "./App"
// In der nativen Hülle den Token in den Schlüsselbund legen statt in
// localStorage. Ohne Hülle passiert hier nichts.
nativeAblageEinrichten()
const wurzel = document.getElementById("wurzel")
if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html")
createRoot(wurzel).render(
<StrictMode>
<App />
</StrictMode>,
)
+112
View File
@@ -0,0 +1,112 @@
/**
* Flacher Navigationszustand — kein Router-Paket.
*
* Übernommen aus dem alten HA-Panel (`route` + `ZURUECK`): Die App hat keinen
* Verlaufsstapel, sondern genau eine aktuelle Seite und eine feste Tabelle,
* welche Seite der Zurück-Pfeil ansteuert. Das ist bewusst so geblieben —
* die Verschachtelung ist flach genug (höchstens drei Ebenen), und ein Router
* würde nur Gewicht ohne Nutzen bringen.
*/
export const HAUPTBEREICHE = ["home", "audi", "trips", "stat", "fuel"] as const
export type Hauptbereich = (typeof HAUPTBEREICHE)[number]
export type SeitenName =
| Hauptbereich
// Unterseiten von "Mein Audi"
| "ident"
| "battverlauf"
| "service"
| "werkstatt"
| "sbuch"
| "reifen"
| "vers"
| "beitrag"
| "vertragsdetails"
| "schutz"
| "notruf"
| "steuer"
// Detailseiten der Listen
| "trip"
| "fill"
// Sonstige
| "sicherheit"
| "live"
| "einst"
export interface Route {
name: SeitenName
/** Datensatz-Kennung bei Detailseiten (Fahrt, Tankvorgang, Servicebuch). */
id?: string
}
/**
* Ziel des Zurück-Pfeils je Unterseite. Seiten, die hier fehlen, sind
* Hauptbereiche und zeigen keinen Zurück-Pfeil.
*/
export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
trip: "trips",
fill: "fuel",
service: "audi",
reifen: "audi",
ident: "audi",
battverlauf: "audi",
vers: "audi",
beitrag: "vers",
vertragsdetails: "vers",
schutz: "vertragsdetails",
steuer: "vers",
notruf: "vers",
werkstatt: "service",
sbuch: "service",
einst: "home",
sicherheit: "home",
live: "home",
}
/** Überschrift je Seite. Leer bedeutet: die Seite bringt ihre eigene mit. */
export const TITEL: Record<SeitenName, string> = {
home: "Übersicht",
audi: "Mein Audi",
trips: "Fahrten",
stat: "Statistik",
fuel: "Tanken",
ident: "Fahrzeugdaten",
battverlauf: "Batterie",
service: "Service",
werkstatt: "Werkstatt",
sbuch: "Servicebuch",
reifen: "Reifen",
vers: "Versicherung & Steuer",
beitrag: "Beitrag",
vertragsdetails: "Vertragsdetails",
schutz: "Schutzbrief",
notruf: "Notruf",
steuer: "Kfz-Steuer",
trip: "Fahrt",
fill: "Tankvorgang",
sicherheit: "Fahrzeugstatus",
live: "Fahrt läuft",
einst: "Einstellungen",
}
export function istHauptbereich(name: SeitenName): name is Hauptbereich {
return (HAUPTBEREICHE as readonly string[]).includes(name)
}
/**
* Der Hauptbereich, zu dem eine Seite gehört — bestimmt, welcher Eintrag in
* Tab-Leiste bzw. Seitenleiste hervorgehoben wird. Folgt der ZURUECK-Kette
* nach oben.
*/
export function bereichVon(name: SeitenName): Hauptbereich {
let aktuell: SeitenName = name
for (let tiefe = 0; tiefe < 5; tiefe++) {
if (istHauptbereich(aktuell)) return aktuell
const eltern = ZURUECK[aktuell]
if (!eltern) return "home"
aktuell = eltern
}
return "home"
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Batteriespannung im Verlauf. Vorlage: `vBatterieverlauf()`.
*
* Das Diagramm ist von Hand als SVG gezeichnet — wie im alten Panel und aus
* demselben Grund: eine Diagrammbibliothek wäre um ein Vielfaches größer als
* die ganze Seite.
*
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
* anhebt); Werte über 13,2 V gelten als „während der Fahrt gemessen" und
* taugen nicht als Ruhewert.
*/
import { useEffect, useState } from "react"
import { Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
datum: string
min: number | null
max: number | null
min_ts?: string
max_ts?: string
}
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen. */
const AGM_RUHE_MAX_V = 13.2
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [
[12.8, 100],
[12.6, 90],
[12.4, 75],
[12.2, 50],
[12.0, 25],
[11.8, 10],
]
function ladezustand(spannung: number): number | null {
if (spannung > AGM_RUHE_MAX_V) return null
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
if (ersterTreffer) return ersterTreffer[1]
return 0
}
export function Batterie() {
const { api, fahrzeug } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
useEffect(() => {
void api.rest
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
.catch(() => setzeFehler(true))
}, [api])
if (fehler) {
return (
<Leerzustand
titel="Verlauf nicht abrufbar"
text="Der Server hat den Batterieverlauf nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
/>
)
}
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null)
if (messwerte.length === 0) {
return (
<Leerzustand
titel="Keine Messwerte"
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor. Sobald einer verfügbar ist, zeichnet die App den Verlauf hier auf."
/>
)
}
const letzter = messwerte[messwerte.length - 1]!
const soc = letzter.min != null ? ladezustand(letzter.min) : null
// 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
return (
<>
<Tile>
<span className="ads-eyebrow">Ruhespannung</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{letzter.min != null ? de(letzter.min, 2) : "—"}
<span className="dm-detail__einheit">V</span>
</div>
</div>
<Verlaufsdiagramm werte={messwerte} />
<Werteliste
kinder={
<>
<Wertzeile label="Letzte Messung" wert={datum(letzter.datum)} />
<Wertzeile
label="Geschätzter Ladezustand"
wert={soc != null ? `${de(soc)} %` : "—"}
zusatz={soc == null ? "Messung nicht in Ruhe aufgenommen" : undefined}
/>
<Wertzeile
label="Trend über den Zeitraum"
wert={trend != null ? `${trend >= 0 ? "+" : ""}${de(trend, 2)} V` : "—"}
/>
<Wertzeile
label="Aktuell gemeldet"
wert={
fahrzeug?.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "—"
}
/>
</>
}
/>
<p className="dm-fussnote">
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.
</p>
</Tile>
</>
)
}
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
const breite = 320
const hoehe = 120
const rand = 4
const alleWerte = werte.flatMap((t) => [t.min, t.max].filter((v): v is number => v != null))
const kleinster = Math.min(...alleWerte) - 0.1
const groesster = Math.max(...alleWerte) + 0.1
const spanne = Math.max(0.2, groesster - kleinster)
const x = (i: number) =>
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
const y = (v: number) => hoehe - rand - ((v - kleinster) / spanne) * (hoehe - 2 * rand)
const linie = (auswahl: (t: Tageswert) => number | null) =>
werte
.map((t, i) => {
const v = auswahl(t)
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
})
.filter(Boolean)
.join(" ")
return (
<svg
className="dm-diagramm"
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
>
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
</svg>
)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Fahrzeugbild mit Platzhalter.
*
* Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit
* `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string`
* nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert
* es hier einmal.
*/
import { ImagePlaceholder } from "@audi-dash/ui"
import { bildUrl } from "./bilder"
export function Bild({
datei,
beschreibung,
name,
groesse,
}: {
datei: string | null | undefined
beschreibung: string
name: string
groesse?: "default" | "mini" | "round"
}) {
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"
return (
<ImagePlaceholder
{...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})}
{...(mitDateiname && datei ? { hint: datei } : {})}
alt={beschreibung}
label={name}
/>
)
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Ersteinrichtung: Die App holt ihre Daten vom eigenen Server, also braucht
* sie einmalig dessen Adresse und ein Zugriffstoken.
*
* Entwurf: `design/DM360.dc.html`, Abschnitt `isSetup`. Fehlermeldungen sind
* bewusst nach Ursache getrennt — „Token abgelehnt" und „Server nicht
* erreichbar" verlangen völlig verschiedene Reaktionen des Benutzers.
*/
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
import { zugangMerken } from "./zugang"
export function Einrichtung({ fertig }: { fertig: () => void }) {
const [adresse, setzeAdresse] = useState("")
const [token, setzeToken] = useState("")
const [laeuft, setzeLaeuft] = useState(false)
const [fehler, setzeFehler] = useState<string | null>(null)
const verbinden = async () => {
setzeFehler(null)
if (!adresse.trim()) {
setzeFehler("Bitte die Adresse deines Servers eintragen.")
return
}
if (!token.trim()) {
setzeFehler("Bitte den Zugangs-Token einfügen.")
return
}
setzeLaeuft(true)
try {
const zugang = { basisUrl: basisUrlNormalisieren(adresse), token: token.trim() }
// Erst prüfen, dann speichern — sonst merkt sich die App Zugangsdaten,
// die nachweislich nicht funktionieren.
await new HassRest(zugang).verbindungPruefen()
await zugangSpeichern(zugang)
await zugangMerken()
fertig()
} catch (ursache) {
if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) {
setzeFehler("Der Server hat den Token abgelehnt. Wurde er vollständig eingefügt?")
} else if (ursache instanceof ApiFehler && ursache.istNetzproblem) {
setzeFehler(
"Der Server ist unter dieser Adresse nicht erreichbar. Stimmen Adresse und Port, und ist das Gerät im richtigen Netz?",
)
} else {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
}
} finally {
setzeLaeuft(false)
}
}
return (
<div className="dm-einrichtung">
<div className="dm-einrichtung__karte">
<span className="ads-eyebrow">DataMetric360</span>
<h1 className="dm-einrichtung__titel">Willkommen</h1>
<p className="dm-einrichtung__text">
Die App holt ihre Daten von deinem eigenen Server. Trag einmal die Adresse ein und füge
deinen Zugangs-Token ein. Danach läuft alles von selbst.
</p>
<Tile>
<Feld label="Server-Adresse">
<input
className="dm-eingabe"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="192.168.1.20:8123"
value={adresse}
onChange={(e) => setzeAdresse(e.target.value)}
/>
</Feld>
<Feld label="Zugangs-Token" last>
<input
className="dm-eingabe"
type="password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="einfügen"
value={token}
onChange={(e) => setzeToken(e.target.value)}
/>
</Feld>
</Tile>
{fehler && (
<p className="dm-einrichtung__fehler" role="alert">
{fehler}
</p>
)}
<div className="dm-einrichtung__knoepfe">
<ActionButton onClick={() => void verbinden()} disabled={laeuft}>
{laeuft ? "Verbinde …" : "Verbinden"}
</ActionButton>
</div>
<p className="dm-einrichtung__hinweis">
Den Token erzeugst du in Home Assistant unter Profil Sicherheit Langlebige
Zugriffstoken". Deine Daten bleiben auf deinem Server.
</p>
</div>
</div>
)
}
+357
View File
@@ -0,0 +1,357 @@
/**
* Einstellungen. Vorlage: `vEinst()` im alten Panel — die umfangreichste
* Seite: Fahrzeugdaten, Darstellung, Fotos, SmartDeal, Pausenzeit, Sicherung,
* Datenausgabe und Zugang.
*/
import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { zugangVerwerfen } from "../api"
import { useDaten } from "../daten/DatenKontext"
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 } from "./bilder"
import { csvHerunterladen } from "./csv"
import { zugangVergessen } from "./zugang"
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
beiAbmeldung,
}: {
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}) {
const { einstellungen, fahrzeug, fahrten, tankvorgaenge, rohprofil, api, profilSpeichern } =
useDaten()
const [theme, themeSetzen] = useTheme()
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const dateiwahl = useRef<HTMLInputElement | null>(null)
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
const aendern = (teil: Partial<EinstellungenWerte>) =>
setzeEntwurf({ ...werte, ...teil })
const speichern = async () => {
if (!entwurf) return
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({ einstellungen: entwurf })
setzeEntwurf(null)
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const abmelden = async () => {
if (!bestaetigen("Zugang von diesem Gerät entfernen? Die Daten auf dem Server bleiben.")) return
await zugangVerwerfen()
zugangVergessen()
beiAbmeldung()
}
const profilExportieren = () => {
const inhalt = JSON.stringify(
{ profil: rohprofil, fahrten, tankvorgaenge, erzeugt: new Date().toISOString() },
null,
2,
)
const blob = new Blob([inhalt], { type: "application/json" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `datametric360-sicherung-${isoTag()}.json`
verweis.click()
URL.revokeObjectURL(url)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Fahrzeug</span>
<Feld label="Modell">
<input
className="dm-eingabe"
value={werte.fahrzeugtitel}
onChange={(e) => aendern({ fahrzeugtitel: e.target.value })}
/>
</Feld>
<Feld label="Zusatz">
<input
className="dm-eingabe"
value={werte.fahrzeugzusatz}
onChange={(e) => aendern({ fahrzeugzusatz: e.target.value })}
/>
</Feld>
<Feld label="Kennzeichen">
<input
className="dm-eingabe"
value={werte.kennzeichen}
onChange={(e) => aendern({ kennzeichen: e.target.value })}
/>
</Feld>
<Feld label="Tankvolumen" unit="l">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.tankvolumen}
onChange={(e) => aendern({ tankvolumen: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="WLAN des Fahrzeugs" last>
<input
className="dm-eingabe"
value={werte.wlanName}
onChange={(e) => aendern({ wlanName: e.target.value })}
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrten</span>
<Feld label="Pause zwischen zwei Fahrten" unit="Minuten">
<input
className="dm-eingabe"
inputMode="numeric"
value={werte.pauseMin}
onChange={(e) => aendern({ pauseMin: Number(e.target.value) || 0 })}
/>
</Feld>
<Feld label="Nacht beginnt">
<input
className="dm-eingabe"
type="time"
value={werte.nachtVon}
onChange={(e) => aendern({ nachtVon: e.target.value })}
/>
</Feld>
<Feld label="Nacht endet" last>
<input
className="dm-eingabe"
type="time"
value={werte.nachtBis}
onChange={(e) => aendern({ nachtBis: e.target.value })}
/>
</Feld>
<p className="dm-fussnote">
Setzt das Fahrzeug innerhalb der Pausenzeit die Fahrt fort, zählt das als eine Fahrt und
nicht als zwei etwa bei einem Tankstopp.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Feld label="Erscheinungsbild">
<Seg
options={[
{ value: "nacht", label: "Nacht" },
{ value: "tag", label: "Tag" },
]}
value={theme}
onChange={(neu) => themeSetzen(neu as "nacht" | "tag")}
/>
</Feld>
<Feld label="Beschriftung der Tab-Leiste" last>
<Switch
checked={tabBeschriftung}
onChange={setzeTabBeschriftung}
aria-label="Beschriftung der Tab-Leiste"
/>
</Feld>
</Tile>
<Tile>
<span className="ads-eyebrow">Bild auf der Übersicht</span>
<Feld label="Verwendetes Bild" last>
<select
className="dm-auswahl"
value={werte.startbild}
onChange={(e) => aendern({ startbild: e.target.value })}
>
{BILDPLAETZE.map((platz) => (
<option key={platz.datei} value={platz.datei}>
{platz.name}
</option>
))}
<option value="seitenansicht-winter.webp">Seitenansicht Winter</option>
</select>
</Feld>
<div className="dm-galerie">
{BILDPLAETZE.map((platz) => (
<Bild
key={platz.datei}
datei={platz.datei}
beschreibung={platz.beschreibung}
name={platz.name}
groesse="mini"
/>
))}
</div>
<p className="dm-fussnote">
Fotos lädst du über die Verwaltung im Home Assistant hoch. Die Dateinamen sind fest
vergeben der Server nimmt nur diese an.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tankrabatt</span>
<Feld label={`${werte.markenname}-Rabatt aktiv`}>
<Switch
checked={fahrzeug.smartdeal.aktiv}
onChange={(an) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, aktiv: an },
},
})
}
aria-label="Tankrabatt aktiv"
/>
</Feld>
<Feld label="Gültig bis" last>
<input
className="dm-eingabe"
type="date"
value={fahrzeug.smartdeal.laeuftAb ?? ""}
onChange={(e) =>
void profilSpeichern({
fahrzeug: {
...fahrzeug,
smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null },
},
})
}
/>
</Feld>
<p className="dm-fussnote">
Das Datum gilt einschließlich am Stichtag selbst zählt der Rabatt noch, erst am Folgetag
schaltet er ab.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Sicherung</span>
<Feld label="Automatische Sicherung">
<select
className="dm-auswahl"
value={werte.backupIntervall}
onChange={(e) => aendern({ backupIntervall: e.target.value })}
>
<option value="aus">aus</option>
<option value="taeglich">täglich</option>
<option value="woechentlich">wöchentlich</option>
<option value="monatlich">monatlich</option>
</select>
</Feld>
<Werteliste
kinder={
<Wertzeile
label="Zuletzt gesichert"
wert={werte.letztesBackup ? datumZeit(werte.letztesBackup) : "—"}
/>
}
/>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt")
}
>
Jetzt sichern
</ActionButton>
<ActionButton onClick={profilExportieren}>Auf dieses Gerät laden</ActionButton>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Daten ausgeben</span>
<p className="dm-fussnote">
Als CSV mit Semikolon und deutschem Zahlenformat so öffnet Excel die Datei ohne
Nachfragen.
</p>
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
csvHerunterladen(
"fahrten",
["Beginn", "Ende", "Strecke km", "Dauer s", "Art", "Status"],
fahrten.map((f) => [
f.ts_start,
f.ts_end,
f.distance_km ?? "",
f.duration_s,
f.art,
f.status,
]),
)
}
>
Fahrten
</ActionButton>
<ActionButton
onClick={() =>
csvHerunterladen(
"tankvorgaenge",
["Zeitpunkt", "Liter", "Betrag", "Preis je Liter", "Tankstelle", "Kilometerstand"],
tankvorgaenge.map((t) => [
t.ts,
t.liters ?? "",
t.fuel_total_eur ?? "",
t.price_per_l ?? "",
t.station_name ?? "",
t.odometer_km ?? "",
]),
)
}
>
Tankvorgänge
</ActionButton>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrten gespeichert" wert={de(fahrten.length)} />
<Wertzeile label="Tankvorgänge gespeichert" wert={de(tankvorgaenge.length)} />
</>
}
/>
<div style={{ marginTop: 14 }}>
<ActionButton variant="destructive" onClick={() => void abmelden()}>
Verbindung trennen
</ActionButton>
</div>
</Tile>
<input ref={dateiwahl} type="file" accept="application/json" hidden />
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</ActionButton>
<button type="button" className="dm-textknopf" onClick={() => setzeEntwurf(null)}>
Verwerfen
</button>
</div>
)}
</>
)
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Eine einzelne Fahrt. Vorlage: `vTrip(id)` im alten Panel — mit einem
* bewussten Unterschied:
*
* Das alte Panel zeichnete über `fakeTrack()` eine **erfundene** Linie
* zwischen zwei Punkten, wenn keine echte Route vorlag. Das ist hier nicht
* übernommen. Solange das Fahrzeug keine Positionsdaten liefert (bis zum
* FMM003 liefert es keine), zeigt die Seite ehrlich, dass keine Route bekannt
* ist, statt eine glaubwürdig aussehende Erfindung zu zeigen.
*/
import { Pill, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, dauer, de, deOderStrich, uhrzeit } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { Karte } from "./Karte"
export function FahrtDetail({ id }: { id: string | undefined }) {
const { fahrten } = useDaten()
const fahrt = fahrten.find((f) => f.trip_id === id)
if (!fahrt) {
return (
<Leerzustand
titel="Fahrt nicht gefunden"
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
/>
)
}
const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null
return (
<>
<Tile>
<span className="ads-eyebrow">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr
</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(fahrt.distance_km, 1)}
<span className="dm-detail__einheit">km</span>
</div>
<div className="dm-detail__marken">
{fahrt.art === "arbeitsweg" ? <Pill variant="work">Arbeitsweg</Pill> : <Pill>Privat</Pill>}
{fahrt.status === "offen" && <Pill>noch offen</Pill>}
{fahrt.source === "manual" && <Pill>selbst eingetragen</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile label="Dauer" wert={dauer(fahrt.duration_s)} />
<Wertzeile
label="Kilometerstand Beginn"
wert={fahrt.odo_start != null ? `${de(fahrt.odo_start)} km` : "—"}
/>
<Wertzeile
label="Kilometerstand Ende"
wert={fahrt.odo_end != null ? `${de(fahrt.odo_end)} km` : "—"}
/>
<Wertzeile
label="Durchschnitt"
wert={
fahrt.distance_km != null && fahrt.duration_s > 0
? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h`
: "—"
}
/>
</>
}
/>
</Tile>
{fahrt.status === "offen" && (
<p className="dm-fussnote">
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.
</p>
)}
<Tile>
<span className="ads-eyebrow">Strecke</span>
{hatPositionen ? (
<Karte
start={{ lat: fahrt.start_lat!, lon: fahrt.start_lon! }}
{...(fahrt.end_lat != null && fahrt.end_lon != null
? { ziel: { lat: fahrt.end_lat, lon: fahrt.end_lon } }
: {})}
/>
) : (
<Leerzustand
titel="Keine Route aufgezeichnet"
text="Das Fahrzeug liefert derzeit keine Positionsdaten. Sobald es das tut, erscheint hier die gefahrene Strecke auf der Karte."
/>
)}
{(fahrt.start_address || fahrt.end_address) && (
<Werteliste
kinder={
<>
<Wertzeile label="Start" wert={fahrt.start_address ?? "—"} />
<Wertzeile label="Ziel" wert={fahrt.end_address ?? "—"} />
</>
}
/>
)}
</Tile>
</>
)
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Fahrtenliste, nach Jahr und Monat aufklappbar. Vorlage: `vTrips()` und
* `vTripsFormular()` im alten Panel.
*/
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, 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 type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Zeilenmenue } from "./Zeilenmenue"
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)
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{fahrten.length === 0
? "Keine Fahrten"
: `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Fahrt eintragen"}
</button>
</div>
{formularOffen && <FahrtFormular beiFertig={() => setzeFormularOffen(false)} />}
{fahrten.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Fahrten"
text="Die App erkennt Fahrten automatisch, sobald sich das Fahrzeug meldet. Bis dahin kannst du Fahrten auch selbst eintragen."
aktion={{ beschriftung: "Fahrt eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${de(summe(jahr.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={`${de(summe(monat.eintraege, (f) => f.distance_km ?? 0))} km`}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((fahrt) => (
<Zeilenmenue
key={fahrt.trip_id}
loeschFrage={`Fahrt vom ${datum(fahrt.ts_start)} löschen?`}
beiLoeschen={() => void api.fahrtLoeschen(fahrt.trip_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("trip", fahrt.trip_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(fahrt.distance_km, 1)} km
</span>
<span className="dm-listenzeile__unter">
{datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} Uhr ·{" "}
{dauer(fahrt.duration_s)}
</span>
</span>
{fahrt.art === "arbeitsweg" && <Pill variant="work">Arbeitsweg</Pill>}
{fahrt.status === "offen" && <Pill>offen</Pill>}
</button>
}
/>
))}
</Accordion>
))}
</Accordion>
))}
</>
)
}
/** Manuelles Eintragen — für Fahrten, die die automatische Erkennung verpasst hat. */
function FahrtFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const [start, setzeStart] = useState("")
const [ende, setzeEnde] = useState("")
const [art, setzeArt] = useState<"privat" | "arbeitsweg">("privat")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const speichern = async () => {
setzeFehler(null)
if (!start || !ende) {
setzeFehler("Bitte Beginn und Ende angeben.")
return
}
if (new Date(ende) <= new Date(start)) {
setzeFehler("Das Ende muss nach dem Beginn liegen.")
return
}
setzeLaeuft(true)
try {
await api.fahrtAnlegen(new Date(start).toISOString(), new Date(ende).toISOString(), art)
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Fahrt eintragen</span>
<Feld label="Beginn">
<input
className="dm-eingabe"
type="datetime-local"
value={start}
onChange={(e) => setzeStart(e.target.value)}
/>
</Feld>
<Feld label="Ende">
<input
className="dm-eingabe"
type="datetime-local"
value={ende}
onChange={(e) => setzeEnde(e.target.value)}
/>
</Feld>
<Feld label="Art" last>
<select
className="dm-auswahl"
value={art}
onChange={(e) => setzeArt(e.target.value as "privat" | "arbeitsweg")}
>
<option value="privat">Privat</option>
<option value="arbeitsweg">Arbeitsweg</option>
</select>
</Feld>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<p className="dm-fussnote">
Die Strecke ergänzt die App selbst, sobald sie den Kilometerstand zu Beginn und Ende der
Fahrt kennt.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Fahrzeugdaten: Identität, technisches Datenblatt, Ausstattung.
* Vorlage: `vIdent()` im alten Panel. Rein anzeigend — die Inhalte pflegt
* man im Profil, nicht hier.
*/
import { Accordion, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import { Wertzeile, Werteliste } from "./bausteine"
interface TechnikGruppe {
gruppe?: string
posten?: [string, string][]
}
interface AusstattungGruppe {
gruppe?: string
sonder?: string[]
serie?: string[]
}
export function Fahrzeugdaten() {
const { fahrzeug, einstellungen } = useDaten()
if (!fahrzeug || !einstellungen) return null
const technik = Array.isArray(fahrzeug.technik)
? (fahrzeug.technik as TechnikGruppe[])
: (Object.values(fahrzeug.technik) as TechnikGruppe[])
const ausstattung = Array.isArray(fahrzeug.ausstattung)
? (fahrzeug.ausstattung as AusstattungGruppe[])
: []
return (
<>
<Tile>
<span className="ads-eyebrow">Identität</span>
<Werteliste
kinder={
<>
<Wertzeile label="Modell" wert={einstellungen.fahrzeugtitel || "—"} />
<Wertzeile label="Ausführung" wert={einstellungen.ausfuehrung || fahrzeug.details || "—"} />
<Wertzeile label="Fahrgestellnummer" wert={fahrzeug.fin || "—"} />
<Wertzeile label="Kennzeichen" wert={einstellungen.kennzeichen || "—"} />
<Wertzeile
label="Erstzulassung"
wert={fahrzeug.erstzulassung ? datum(fahrzeug.erstzulassung) : "—"}
/>
<Wertzeile
label="Hauptuntersuchung fällig"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
<Wertzeile
label="Kilometerstand"
wert={fahrzeug.odoBekannt ? `${de(fahrzeug.odo)} km` : "—"}
/>
</>
}
/>
</Tile>
{technik.length > 0 && (
<Tile>
<span className="ads-eyebrow">Technische Daten</span>
{technik.map((gruppe, i) => (
<Accordion key={gruppe.gruppe ?? i} title={gruppe.gruppe ?? "Weitere Angaben"}>
<Werteliste
kinder={(gruppe.posten ?? []).map(([bezeichnung, wert]) => (
<Wertzeile key={bezeichnung} label={bezeichnung} wert={wert} />
))}
/>
</Accordion>
))}
</Tile>
)}
{ausstattung.length > 0 && (
<Tile>
<span className="ads-eyebrow">Ausstattung</span>
{ausstattung.map((gruppe, i) => (
<Accordion
key={gruppe.gruppe ?? i}
title={gruppe.gruppe ?? "Weitere Ausstattung"}
summary={`${(gruppe.sonder?.length ?? 0) + (gruppe.serie?.length ?? 0)}`}
>
<ul className="dm-liste">
{(gruppe.sonder ?? []).map((posten) => (
<li key={posten}>{posten}</li>
))}
{(gruppe.serie ?? []).map((posten) => (
<li key={posten} className="dm-liste__serie">
{posten}
</li>
))}
</ul>
</Accordion>
))}
</Tile>
)}
</>
)
}
@@ -0,0 +1,80 @@
/**
* Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel.
*
* Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend:
* `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt
* dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage
* unbekannt, sobald eine einzige Prüfung unbekannt ist.
*/
import { StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datumZeit } from "../format"
import { Leerzustand } from "./bausteine"
export function Fahrzeugstatus() {
const { fahrzeug, statusStand } = useDaten()
if (!fahrzeug) return null
const punkte = fahrzeug.sicherheitscheck
if (punkte.length === 0) {
return (
<Leerzustand
titel="Keine Zustandsdaten"
text="Das Fahrzeug meldet derzeit keine Tür-, Fenster- und Schlossdaten. Sobald es das tut, stehen sie hier."
/>
)
}
const unbekannt = punkte.filter((p) => p.ok === null).length
const offen = punkte.filter((p) => p.ok === false)
return (
<>
<StatusRow
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
title={
fahrzeug.gesichert === true
? "Alles verschlossen"
: fahrzeug.gesichert === false
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
: "Zustand nicht vollständig bekannt"
}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
/>
<Tile>
<span className="ads-eyebrow">Einzelprüfungen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
</Tile>
{unbekannt > 0 && (
<p className="dm-fussnote">
{unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das
Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als
unbekannt statt als sicher.
</p>
)}
</>
)
}
@@ -0,0 +1,48 @@
/**
* Offline-Hinweis und Warteschlange über der Kopfzeile.
*
* Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Ohne Verbindung bleiben
* die zuletzt geladenen Daten sichtbar, sind aber erkennbar nicht aktuell —
* nie ein leerer Bildschirm und nie eine stille Veraltung.
*/
import type { Verbindungszustand, WartenderAuftrag } from "../api"
import { SymbolWarnung } from "../symbole"
export function Hinweisleiste({
verbindung,
warteschlange,
}: {
verbindung: Verbindungszustand
warteschlange: readonly WartenderAuftrag[]
}) {
const offline = verbindung !== "verbunden"
const wartend = warteschlange.length
// "verbindet" gleich beim Start nicht als Offline melden — das würde bei
// jedem App-Start kurz aufblitzen.
if (!offline && wartend === 0) return null
if (verbindung === "verbindet" && wartend === 0) return null
const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
return (
<div className={offline ? "dm-hinweis dm-hinweis--offline" : "dm-hinweis"} role="status">
<SymbolWarnung groesse={16} />
<span>
{offline
? verbindung === "nicht_angemeldet"
? "Nicht angemeldet — bitte Zugang prüfen."
: "Offline — angezeigte Daten sind der letzte bekannte Stand."
: null}
{offline && wartend > 0 ? " " : null}
{wartend > 0
? wartend === 1
? "Eine Änderung wartet auf Übertragung."
: `${wartend} Änderungen warten auf Übertragung.`
: null}
{gescheitert > 0 ? ` ${gescheitert} davon mit Fehlversuchen.` : null}
</span>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Karte für Fahrtdetails.
*
* Leaflet kommt hier **aus dem Bündel**, nicht von einem CDN. Im alten Panel
* wurde es zur Laufzeit nachgeladen, was auf einem Handy ohne eigenen
* Internetzugang (nur über den VPN-Tunnel erreichbar) stillschweigend nichts
* anzeigte — ein offener Befund des Audits. Die Kacheln selbst bleiben
* zwangsläufig entfernt; fehlen sie, bleibt wenigstens die Karte samt Markern
* bedienbar statt leer.
*/
import { useEffect, useRef } from "react"
import "leaflet/dist/leaflet.css"
import { useTheme } from "../theme"
export interface Punkt {
lat: number
lon: number
}
const KACHELN = {
nacht: {
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
zusatz: "© OpenStreetMap, © CARTO",
},
tag: {
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
zusatz: "© OpenStreetMap, © CARTO",
},
} as const
export function Karte({ start, ziel }: { start: Punkt; ziel?: Punkt }) {
const behaelter = useRef<HTMLDivElement | null>(null)
const [theme] = useTheme()
useEffect(() => {
const element = behaelter.current
if (!element) return
let karte: import("leaflet").Map | null = null
let abgebrochen = false
// Dynamisch geladen, damit Leaflet nicht im Startbündel jeder Seite liegt —
// gebündelt wird es trotzdem, es kommt aus node_modules, nicht aus dem Netz.
void import("leaflet").then((L) => {
if (abgebrochen) return
karte = L.map(element, { attributionControl: true, scrollWheelZoom: false })
const kachel = KACHELN[theme]
L.tileLayer(kachel.url, { attribution: kachel.zusatz, maxZoom: 19 }).addTo(karte)
const punkte: [number, number][] = [[start.lat, start.lon]]
L.circleMarker([start.lat, start.lon], { radius: 6, color: "#F50537" })
.bindTooltip("Start")
.addTo(karte)
if (ziel) {
punkte.push([ziel.lat, ziel.lon])
L.circleMarker([ziel.lat, ziel.lon], { radius: 6, color: "#F50537", fill: true })
.bindTooltip("Ziel")
.addTo(karte)
// Verbindungslinie NUR zwischen echten Punkten, kein erfundener Verlauf.
L.polyline(punkte, { color: "#F50537", weight: 2, dashArray: "4 6" }).addTo(karte)
}
if (punkte.length > 1) {
karte.fitBounds(punkte, { padding: [24, 24] })
} else {
karte.setView(punkte[0]!, 14)
}
})
return () => {
abgebrochen = true
karte?.remove()
}
}, [start, ziel, theme])
return <div ref={behaelter} className="dm-karte" role="img" aria-label="Karte der Fahrt" />
}
+87
View File
@@ -0,0 +1,87 @@
/**
* „Fahrt läuft gerade" — Live-Ansicht mit Karte, Geschwindigkeit, bisheriger
* Strecke und Dauer.
*
* Steht bereit, ist aber **abgeschaltet**: Die Daten dafür (Zündung, Position,
* Geschwindigkeit) liefert erst der FMM003, und der ist noch nicht verbaut
* (COMPANION_APP_ARCHITECTURE.md §2b, Phase 13 des Umsetzungsplans). Der
* Schalter liegt in `funktionen.ts` — umlegen, sobald echte Werte fließen,
* nicht vorher: bis dahin gäbe es nichts anzuzeigen außer Nullen.
*/
import { Fig, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { dauer, de, deOderStrich } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
import { Karte } from "./Karte"
export interface LiveZustand {
faehrt: boolean
geschwindigkeit?: number | null
position?: { lat: number; lon: number } | null
streckeKm?: number | null
dauerS?: number | null
}
/** Liest die Live-Werte aus dem Fahrzeugstatus. Solange der FMM003 fehlt,
liefert das Backend diese Felder nicht — dann ist alles null. */
export function liveZustandLesen(status: Record<string, unknown>): LiveZustand {
const lat = typeof status["lat"] === "number" ? status["lat"] : null
const lon = typeof status["lon"] === "number" ? status["lon"] : null
return {
faehrt: status["zuendung"] === true || status["zuendung"] === 1,
geschwindigkeit: typeof status["geschwindigkeit"] === "number" ? status["geschwindigkeit"] : null,
position: lat != null && lon != null ? { lat, lon } : null,
streckeKm: typeof status["fahrt_strecke_km"] === "number" ? status["fahrt_strecke_km"] : null,
dauerS: typeof status["fahrt_dauer_s"] === "number" ? status["fahrt_dauer_s"] : null,
}
}
export function LiveFahrt() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const live = liveZustandLesen(fahrzeug as unknown as Record<string, unknown>)
if (!live.faehrt) {
return (
<Leerzustand
titel="Das Fahrzeug steht"
text="Sobald die Zündung an ist, siehst du hier die laufende Fahrt mit Position, Geschwindigkeit, Strecke und Dauer."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Geschwindigkeit</span>
<Fig value={deOderStrich(live.geschwindigkeit)} unit="km/h" size={56} />
<Werteliste
kinder={
<>
<Wertzeile
label="Bisher gefahren"
wert={live.streckeKm != null ? `${de(live.streckeKm, 1)} km` : "—"}
/>
<Wertzeile label="Unterwegs seit" wert={dauer(live.dauerS)} />
</>
}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Position</span>
{live.position ? (
<Karte start={live.position} />
) : (
<Leerzustand
titel="Keine Position"
text="Das Fahrzeug meldet gerade keine Koordinaten."
/>
)}
</Tile>
</>
)
}
+72
View File
@@ -0,0 +1,72 @@
/**
* „Mein Audi" — Sammelseite mit Fotogalerie und Wegen zu den Unterseiten.
* Vorlage: `vAudi()` im alten Panel.
*/
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { de, deOderStrich } from "../format"
import type { SeitenName } from "../navigation"
import { NaviKachel } from "./bausteine"
import { Bild } from "./Bild"
import { BILDPLAETZE } from "./bilder"
export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen } = useDaten()
if (!fahrzeug || !einstellungen) return null
const aktiverSatz =
fahrzeug.reifen.aktiv === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
return (
<>
<Tile>
<span className="ads-eyebrow">Fotogalerie</span>
<div className="dm-galerie">
{BILDPLAETZE.map((platz) => (
<Bild
key={platz.datei}
datei={platz.datei}
beschreibung={platz.beschreibung}
name={platz.name}
groesse="mini"
/>
))}
</div>
<p className="dm-fussnote">
Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten
Dateinamen, damit klar ist, was wohin gehört.
</p>
</Tile>
<div className="dm-raster">
<NaviKachel
titel="Fahrzeugdaten"
wert={fahrzeug.fin || "Technik und Ausstattung"}
beiKlick={() => geheZu("ident")}
/>
<NaviKachel
titel="Batterie"
wert={
fahrzeug.batteriespannung != null
? `${de(fahrzeug.batteriespannung, 2)} V`
: "Kein Spannungssensor"
}
beiKlick={() => geheZu("battverlauf")}
/>
<NaviKachel titel="Service" wert="Termine und Servicebuch" beiKlick={() => geheZu("service")} />
<NaviKachel
titel="Reifen"
wert={`${fahrzeug.reifen.aktiv} · ${deOderStrich(aktiverSatz.km)} km`}
beiKlick={() => geheZu("reifen")}
/>
<NaviKachel
titel="Versicherung & Steuer"
wert="Beitrag, Vertrag, Kfz-Steuer"
beiKlick={() => geheZu("vers")}
/>
</div>
</>
)
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Reifensätze. Vorlage: `vReifen()` im alten Panel.
*
* Der Kilometerstand je Satz kommt vom Backend (`reifenzaehler.py`) und wird
* hier nie zurückgeschrieben — sonst überschriebe ein im Browser gehaltener
* älterer Stand eine zwischenzeitliche Fortschreibung. Der Wechsel läuft
* deshalb auch über einen eigenen Dienst statt über das Profil.
*/
import { useState } from "react"
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
export function Reifen() {
const { fahrzeug, api, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
const [gezeigt, setzeGezeigt] = useState<"Sommer" | "Winter" | null>(null)
if (!fahrzeug) return null
const aktiv = fahrzeug.reifen.aktiv
const anzeige = gezeigt ?? aktiv
const satz: Reifensatz = anzeige === "Winter" ? fahrzeug.reifen.winter : fahrzeug.reifen.sommer
const wechsel = fahrzeug.reifen.wechsel as { datum?: string; vereinbart?: boolean }
const wechseln = async () => {
const ziel = aktiv === "Sommer" ? "winter" : "sommer"
if (!bestaetigen(`Auf ${ziel === "winter" ? "Winter" : "Sommer"}reifen umstellen?`)) return
setzeLaeuft(true)
try {
// Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und
// schaltet erst dann um, damit keine Kilometer auf den neuen Satz
// rutschen, die noch auf dem alten gefahren wurden.
await api.rest.dienstAufrufen("pyscript", "audi_dashboard_reifen_wechseln", { satz: ziel })
} finally {
setzeLaeuft(false)
}
}
const wechseldatumSetzen = async (neuesDatum: string) => {
await profilSpeichern({
fahrzeug: {
...fahrzeug,
reifen: { ...fahrzeug.reifen, wechsel: { ...wechsel, datum: neuesDatum } },
},
})
}
return (
<>
<Tile>
<span className="ads-eyebrow">Montiert</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">{aktiv}</div>
<ActionButton onClick={() => void wechseln()} disabled={laeuft}>
{laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`}
</ActionButton>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Anzugsmoment der Radschrauben"
wert={
fahrzeug.reifen.anzugsmoment != null
? `${de(fahrzeug.reifen.anzugsmoment)} Nm`
: "—"
}
/>
<Wertzeile
label="Nächster Wechseltermin"
wert={wechsel.datum ? datum(wechsel.datum) : "—"}
/>
</>
}
/>
<Feld label="Wechseltermin" last>
<input
className="dm-eingabe"
type="date"
value={wechsel.datum ?? ""}
onChange={(e) => void wechseldatumSetzen(e.target.value)}
/>
</Feld>
{wechsel.datum && (
<div style={{ marginTop: 14 }}>
<ActionButton
onClick={() =>
kalenderDateiLaden(
"Reifenwechsel",
wechsel.datum!,
`Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`,
)
}
>
In den Kalender eintragen
</ActionButton>
</div>
)}
</Tile>
<Seg
options={[
{ value: "Sommer", label: "Sommer" },
{ value: "Winter", label: "Winter" },
]}
value={anzeige}
onChange={(wert) => setzeGezeigt(wert as "Sommer" | "Winter")}
/>
<Tile>
<span className="ads-eyebrow">
{anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"}
</span>
<Bild
datei={anzeige === "Winter" ? "rad-winter.webp" : "rad-sommer.webp"}
beschreibung={`${anzeige}rad`}
name={`${anzeige}rad`}
/>
<Werteliste
kinder={
<>
<Wertzeile label="Gefahrene Strecke" wert={`${deOderStrich(satz["km"] as number)} km`} />
<Wertzeile label="Größe" wert={(satz["mass"] as string) || "—"} />
<Wertzeile
label="Marke und Modell"
wert={
[satz["marke"], satz["modell"]].filter(Boolean).join(" ") || "—"
}
/>
<Wertzeile label="DOT" wert={(satz["dot"] as string) || "—"} />
<Wertzeile
label="Luftdruck"
wert={`${(satz["druck_vorne"] as string) || "—"} / ${(satz["druck_hinten"] as string) || "—"} bar`}
zusatz="vorne / hinten"
/>
{Boolean(satz["kommentar"]) && (
<Wertzeile label="Notiz" wert={satz["kommentar"] as string} />
)}
</>
}
/>
<p className="dm-fussnote">
Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
rechnet sie immer dem gerade montierten Satz zu.
</p>
</Tile>
</>
)
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Service, Werkstatt und Servicebuch. Vorlagen: `vService()`, `vWerkstatt()`,
* `vSbuch()`.
*
* Die vom Fahrzeug gemeldete Fälligkeit und die aus dem Servicebuch
* gerechnete Prognose stehen bewusst nebeneinander statt sich zu ersetzen:
* Die Logik im Werkscomputer ist nicht bekannt und kann von den selbst
* eingestellten Intervallen abweichen.
*/
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { oelwechselPrognose } from "../daten/service"
import { datum, de, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { kalenderDateiLaden } from "./kalender"
export interface ServicebuchEintrag {
datum?: string
km?: number
art?: string
betrieb?: string
notiz?: string
[weitere: string]: unknown
}
interface Autohaus {
name?: string
adresse?: string
tel?: string
mail?: string
}
export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
const prognose = oelwechselPrognose(fahrzeug, buch)
return (
<>
<Tile>
<span className="ads-eyebrow">Nächster Ölwechsel</span>
<Werteliste
kinder={
<>
<Wertzeile
label="Vom Fahrzeug gemeldet"
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
zusatz={
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Eigene Prognose"
wert={prognose ? datum(prognose.datum) : "—"}
zusatz={
prognose
? `${de(prognose.restKm)} km offen · ${de(prognose.kmProTag, 1)} km/Tag`
: "Noch kein Ölwechsel im Servicebuch"
}
/>
<Wertzeile
label="Nächste Inspektion"
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
zusatz={
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Hauptuntersuchung"
wert={fahrzeug.hu ? datum(fahrzeug.hu) : "—"}
/>
</>
}
/>
{prognose && (
<div style={{ marginTop: 14 }}>
<ActionButton
onClick={() =>
kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose")
}
>
In den Kalender eintragen
</ActionButton>
</div>
)}
<p className="dm-fussnote">
Die eigene Prognose rechnet ab dem letzten Ölwechsel im Servicebuch neu und nutzt die
seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des Fahrzeugs nicht,
sondern ergänzt sie.
</p>
</Tile>
<div className="dm-raster">
<NaviKachel
titel="Werkstatt"
wert={autohaus.name || "Noch nicht hinterlegt"}
beiKlick={() => geheZu("werkstatt")}
/>
<NaviKachel
titel="Servicebuch"
wert={`${de(buch.length)} Eintrag${buch.length === 1 ? "" : "e"}`}
beiKlick={() => geheZu("sbuch")}
/>
</div>
</>
)
}
export function Werkstatt() {
const { fahrzeug, profilSpeichern } = useDaten()
const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus
const [entwurf, setzeEntwurf] = useState<Autohaus>({ ...autohausRoh })
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
const neu = { ...fahrzeug, service: { ...fahrzeug.service, autohaus: entwurf } }
await profilSpeichern({ fahrzeug: neu })
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
const feld = (schluessel: keyof Autohaus, beschriftung: string, letztes = false) => (
<Feld label={beschriftung} last={letztes}>
<input
className="dm-eingabe"
value={entwurf[schluessel] ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, [schluessel]: e.target.value }))}
/>
</Feld>
)
return (
<Tile>
<span className="ads-eyebrow">Werkstatt</span>
{feld("name", "Name")}
{feld("adresse", "Adresse")}
{feld("tel", "Telefon")}
{feld("mail", "E-Mail", true)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Servicebuch() {
const { fahrzeug, profilSpeichern } = useDaten()
const [neuOffen, setzeNeuOffen] = useState(false)
const [entwurf, setzeEntwurf] = useState<ServicebuchEintrag>({ datum: isoTag(), art: "Ölwechsel" })
const [laeuft, setzeLaeuft] = useState(false)
if (!fahrzeug) return null
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
const schreiben = async (neuesBuch: ServicebuchEintrag[]) => {
setzeLaeuft(true)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, service: { ...fahrzeug.service, buch: neuesBuch } },
})
} finally {
setzeLaeuft(false)
}
}
const hinzufuegen = async () => {
await schreiben(
[...buch, entwurf].sort((a, b) => String(b.datum ?? "").localeCompare(String(a.datum ?? ""))),
)
setzeNeuOffen(false)
setzeEntwurf({ datum: isoTag(), art: "Ölwechsel" })
}
const entfernen = async (index: number) => {
if (!bestaetigen("Diesen Servicebuch-Eintrag löschen?")) return
await schreiben(buch.filter((_, i) => i !== index))
}
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">{de(buch.length)} Einträge</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeNeuOffen((offen) => !offen)}
>
{neuOffen ? "Abbrechen" : "Eintrag hinzufügen"}
</button>
</div>
{neuOffen && (
<Tile>
<span className="ads-eyebrow">Neuer Eintrag</span>
<Feld label="Datum">
<input
className="dm-eingabe"
type="date"
value={entwurf.datum ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))}
/>
</Feld>
<Feld label="Art">
<select
className="dm-auswahl"
value={entwurf.art ?? "Ölwechsel"}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, art: e.target.value }))}
>
<option>Ölwechsel</option>
<option>Inspektion</option>
<option>Hauptuntersuchung</option>
<option>Reparatur</option>
<option>Sonstiges</option>
</select>
</Feld>
<Feld label="Kilometerstand" unit="km">
<input
className="dm-eingabe"
inputMode="numeric"
value={entwurf.km ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))}
/>
</Feld>
<Feld label="Betrieb">
<input
className="dm-eingabe"
value={entwurf.betrieb ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, betrieb: e.target.value }))}
/>
</Feld>
<Feld label="Notiz" last>
<input
className="dm-eingabe"
value={entwurf.notiz ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, notiz: e.target.value }))}
/>
</Feld>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void hinzufuegen()} disabled={laeuft}>
Speichern
</ActionButton>
</div>
</Tile>
)}
{buch.length === 0 && !neuOffen && (
<Leerzustand
titel="Servicebuch ist leer"
text="Trag hier ein, was am Fahrzeug gemacht wurde. Aus den Ölwechseln entsteht die eigene Wartungsprognose."
aktion={{ beschriftung: "Eintrag hinzufügen", beiKlick: () => setzeNeuOffen(true) }}
/>
)}
{buch.map((eintrag, index) => (
<Tile key={`${eintrag.datum}-${index}`}>
<div className="dm-detail__kopf">
<div>
<span className="ads-eyebrow">{eintrag.datum ? datum(eintrag.datum) : "ohne Datum"}</span>
<div style={{ fontSize: 17, fontWeight: 300, marginTop: 4 }}>
{eintrag.art ?? "Eintrag"}
</div>
</div>
<button
type="button"
className="dm-textknopf"
onClick={() => void entfernen(index)}
disabled={laeuft}
>
Löschen
</button>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Kilometerstand"
wert={eintrag.km != null ? `${de(eintrag.km)} km` : "—"}
/>
<Wertzeile label="Betrieb" wert={eintrag.betrieb || "—"} />
{eintrag.notiz && <Wertzeile label="Notiz" wert={eintrag.notiz} />}
</>
}
/>
</Tile>
))}
</>
)
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Statistik. Vorlage: `vStat()` im alten Panel — die Rechenregeln stecken in
* `daten/statistik.ts` und sind von dort unverändert übernommen.
*/
import { Fig, ProgressBar, StatGrid, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
import { datum, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
export function Statistik() {
const { fahrten, tankvorgaenge, einstellungen } = useDaten()
if (!einstellungen) return null
if (fahrten.length === 0 && tankvorgaenge.length === 0) {
return (
<Leerzustand
titel="Noch nichts auszuwerten"
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
/>
)
}
const a = jahresauswertung(
fahrten,
tankvorgaenge,
einstellungen.nachtVon,
einstellungen.nachtBis,
)
return (
<div className="dm-raster">
<Tile>
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
<Fig value={de(a.kmJahr)} unit="km" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].km),
unit: "km",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
unit: "h",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Getankte Menge und Kosten {a.jahr}</span>
<Fig value={de(a.literJahr)} unit="l" size={40} />
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].liter),
unit: "l",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
<StatGrid
items={ZEITRAEUME.map((z) => ({
value: de(a.jeZeitraum[z].kosten),
unit: "€",
caption: ZEITRAUM_NAME[z],
}))}
columns={4}
/>
</Tile>
<Tile className="dm-raster__breit">
<span className="ads-eyebrow">Langzeitverbrauch</span>
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
<Werteliste
kinder={
<>
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
<Wertzeile
label="Strecke bis zum letzten Tankstop"
wert={`${de(a.langzeit.km)} km`}
zusatz={a.langzeit.basis !== "" ? a.langzeit.basis : undefined}
/>
<Wertzeile
label="Letzter Tankstop"
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "—"}
/>
</>
}
/>
{a.langzeit.unplausibel && (
<p className="dm-fussnote dm-fussnote--warnung">
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
Kilometerstände vollständig vorliegen, erscheint er hier.
</p>
)}
<p className="dm-fussnote">
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
Tankstop nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
ergibt, sobald eine Fahrt fehlt.
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Tag und Nacht {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
unit="% bei Tag"
size={40}
/>
<ProgressBar
segments={[
{ value: a.tagKm, color: "var(--fg)", label: `Tag · ${de(a.tagKm)} km` },
{ value: a.nachtKm, color: "var(--fg3)", label: `Nacht · ${de(a.nachtKm)} km` },
]}
/>
<p className="dm-fussnote">
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
</p>
</Tile>
<Tile>
<span className="ads-eyebrow">Art der Fahrten {a.jahr}</span>
<Fig
value={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"}
unit="% Arbeitsweg"
size={40}
/>
<ProgressBar
segments={[
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
]}
/>
<Werteliste
kinder={
<>
<Wertzeile label="Fahrten Arbeitsweg" wert={de(a.arbeitFahrten)} />
<Wertzeile label="Fahrten privat" wert={de(a.privatFahrten)} />
<Wertzeile
label={`Kraftstoffkosten ${a.jahr}`}
wert={`${eur(a.kostenJahr)}`}
/>
</>
}
/>
</Tile>
</div>
)
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Ein einzelner Tankvorgang samt Beleg-Upload. Vorlage: `vFill(id)`.
*
* Der Beleg wird als PDF hochgeladen; das Ergebnis kommt **nicht** als
* Rückgabewert des Dienstaufrufs zurück (pyscript liefert keine), sondern
* asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis`.
* Genau dieses Muster hatte auch das alte Panel.
*/
import { useEffect, useRef, useState } from "react"
import { ActionButton, Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de, deOderStrich, eur } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface BelegErgebnis {
ok?: boolean
fehler?: string
meldung?: string
tank_id?: string
[weitere: string]: unknown
}
export function TankDetail({ id }: { id: string | undefined }) {
const { tankvorgaenge, api } = useDaten()
const tank = tankvorgaenge.find((t) => t.tank_id === id)
if (!tank) {
return (
<Leerzustand
titel="Tankvorgang nicht gefunden"
text="Dieser Eintrag existiert nicht mehr. Vielleicht wurde er gerade gelöscht."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">{datumZeit(tank.ts)}</span>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{deOderStrich(tank.liters, 2)}
<span className="dm-detail__einheit">l</span>
</div>
<div className="dm-detail__marken">
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
{tank.source === "beleg" && <Pill>aus Beleg</Pill>}
{tank.source === "manual" && <Pill>selbst eingetragen</Pill>}
{tank.source === "auto" && <Pill>automatisch erkannt</Pill>}
</div>
</div>
<Werteliste
kinder={
<>
<Wertzeile
label="Gesamtbetrag"
wert={tank.fuel_total_eur != null ? `${eur(tank.fuel_total_eur)}` : "—"}
/>
<Wertzeile
label="Preis je Liter"
wert={tank.price_per_l != null ? `${eur(tank.price_per_l)}` : "—"}
zusatz={
tank.list_price_per_l != null
? `Listenpreis ${eur(tank.list_price_per_l)}`
: undefined
}
/>
{tank.discount != null && tank.discount > 0 && (
<Wertzeile label="Ersparnis" wert={`${eur(tank.discount)}`} />
)}
<Wertzeile label="Tankstelle" wert={tank.station_name ?? "—"} zusatz={tank.station_address ?? undefined} />
<Wertzeile label="Kraftstoff" wert={tank.product_name ?? tank.fuel_type ?? "—"} />
<Wertzeile
label="Kilometerstand"
wert={tank.odometer_km != null ? `${de(tank.odometer_km)} km` : "—"}
/>
<Wertzeile
label="Seit der letzten Tankung"
wert={tank.distance_km != null ? `${de(tank.distance_km)} km` : "—"}
/>
</>
}
/>
</Tile>
<BelegBereich tankId={tank.tank_id} belegDatei={tank.receipt_file ?? null} belegNr={tank.receipt_no ?? null} zeitpunkt={tank.ts} api={api} />
</>
)
}
function BelegBereich({
tankId,
belegDatei,
belegNr,
zeitpunkt,
api,
}: {
tankId: string
belegDatei: string | null
belegNr: string | null
zeitpunkt: string
api: ReturnType<typeof useDaten>["api"]
}) {
const dateiwahl = useRef<HTMLInputElement | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const [meldung, setzeMeldung] = useState<{ art: "ok" | "fehler"; text: string } | null>(null)
// Das Ergebnis kommt asynchron über eine eigene Entität, nicht als Antwort
// auf den Dienstaufruf. Nach dem Hochladen wird sie deshalb kurz abgefragt.
const [wartet, setzeWartet] = useState(false)
useEffect(() => {
if (!wartet) return
let abgebrochen = false
const frist = Date.now() + 20_000
const abfragen = async () => {
while (!abgebrochen && Date.now() < frist) {
await new Promise((weiter) => setTimeout(weiter, 1200))
try {
const ergebnis = await api.rest.datenLesen<BelegErgebnis>(ENTITAETEN.belegErgebnis)
if (abgebrochen) return
if (ergebnis && ergebnis.tank_id === tankId) {
setzeWartet(false)
setzeMeldung(
ergebnis.ok === false || ergebnis.fehler
? { art: "fehler", text: ergebnis.fehler ?? "Der Beleg konnte nicht gelesen werden." }
: { art: "ok", text: ergebnis.meldung ?? "Beleg übernommen." },
)
return
}
} catch {
// Weiterversuchen — ein einzelner Fehlschlag heißt nichts.
}
}
if (!abgebrochen) {
setzeWartet(false)
setzeMeldung({
art: "fehler",
text: "Der Server hat noch nicht geantwortet. Der Beleg wird trotzdem verarbeitet — die Seite aktualisiert sich, sobald er da ist.",
})
}
}
void abfragen()
return () => {
abgebrochen = true
}
}, [wartet, api, tankId])
const hochladen = async (datei: File) => {
setzeMeldung(null)
if (datei.type !== "application/pdf" && !datei.name.toLowerCase().endsWith(".pdf")) {
setzeMeldung({ art: "fehler", text: "Bitte den Beleg als PDF auswählen." })
return
}
setzeLaeuft(true)
try {
const base64 = await alsBase64(datei)
await api.belegHochladen(base64, datei.name, tankId)
setzeWartet(true)
} catch (ursache) {
setzeMeldung({
art: "fehler",
text: ursache instanceof Error ? ursache.message : String(ursache),
})
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Beleg</span>
{belegDatei ? (
<Werteliste
kinder={
<>
<Wertzeile label="Hinterlegt" wert={belegDatei} />
<Wertzeile label="Beleg-Nummer" wert={belegNr ?? "—"} />
</>
}
/>
) : (
<p className="dm-fussnote">
Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge,
Betrag, Tankstelle und Ersparnis selbst aus.
</p>
)}
<input
ref={dateiwahl}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => {
const datei = e.target.files?.[0]
if (datei) void hochladen(datei)
e.target.value = ""
}}
/>
{meldung && (
<p
className={meldung.art === "fehler" ? "dm-fehler" : "dm-erfolg"}
role={meldung.art === "fehler" ? "alert" : "status"}
>
{meldung.text}
</p>
)}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => dateiwahl.current?.click()} disabled={laeuft || wartet}>
{laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"}
</ActionButton>
</div>
<p className="dm-fussnote">
Der Beleg muss vom {datum(zeitpunkt)} sein sonst weist der Server ihn ab, damit keine
fremde Tankung in diesen Eintrag gerät.
</p>
</Tile>
)
}
/** Datei als Base64 ohne den `data:`-Vorspann — so erwartet es das Backend. */
function alsBase64(datei: File): Promise<string> {
return new Promise((aufloesen, ablehnen) => {
const leser = new FileReader()
leser.onload = () => {
const ergebnis = String(leser.result)
const komma = ergebnis.indexOf(",")
aufloesen(komma >= 0 ? ergebnis.slice(komma + 1) : ergebnis)
}
leser.onerror = () => ablehnen(new Error("Die Datei konnte nicht gelesen werden."))
leser.readAsDataURL(datei)
})
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Tankvorgänge, nach Jahr und Monat aufklappbar. Vorlage: `vFuel()`.
*
* Der Durchschnittspreis ist immer mengengewichtet (Summe Kosten geteilt durch
* Summe Liter) — eine kleine teure Tankung darf den Schnitt nicht so stark
* heben wie eine große.
*/
import { useState } from "react"
import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { nachJahrUndMonat } from "../daten/statistik"
import { datum, de, deOderStrich, eur, monatJahr, schnittpreis, summe } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand } from "./bausteine"
import { Zeilenmenue } from "./Zeilenmenue"
export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { tankvorgaenge, api } = useDaten()
const [formularOffen, setzeFormularOffen] = useState(false)
const gruppen = nachJahrUndMonat(tankvorgaenge, (t) => t.ts)
return (
<>
<div className="dm-abschnitt">
<span className="ads-eyebrow">
{tankvorgaenge.length === 0
? "Keine Tankvorgänge"
: `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`}
</span>
<button
type="button"
className="dm-textknopf"
onClick={() => setzeFormularOffen((offen) => !offen)}
>
{formularOffen ? "Abbrechen" : "Tankung eintragen"}
</button>
</div>
{formularOffen && <TankFormular beiFertig={() => setzeFormularOffen(false)} />}
{tankvorgaenge.length === 0 && !formularOffen && (
<Leerzustand
titel="Noch keine Tankvorgänge"
text="Die App erkennt Tankungen automatisch am steigenden Füllstand und vervollständigt sie aus dem hochgeladenen Beleg. Du kannst sie auch selbst eintragen."
aktion={{ beschriftung: "Tankung eintragen", beiKlick: () => setzeFormularOffen(true) }}
/>
)}
{gruppen.map((jahr) => (
<Accordion
key={jahr.jahr}
title={String(jahr.jahr)}
summary={`${eur(summe(jahr.eintraege, (t) => t.fuel_total_eur ?? 0))}`}
defaultOpen={jahr === gruppen[0]}
>
{jahr.monate.map((monat) => {
const schnitt = schnittpreis(monat.eintraege)
return (
<Accordion
key={monat.schluessel}
level={2}
title={monatJahr(new Date(monat.jahr, monat.monat, 1))}
summary={schnitt > 0 ? `${eur(schnitt)} €/l` : "—"}
defaultOpen={monat === jahr.monate[0]}
>
{monat.eintraege.map((tank) => (
<Zeilenmenue
key={tank.tank_id}
loeschFrage={`Tankvorgang vom ${datum(tank.ts)} löschen?`}
beiLoeschen={() => void api.tankvorgangLoeschen(tank.tank_id)}
kinder={
<button
type="button"
className="dm-listenzeile"
onClick={() => geheZu("fill", tank.tank_id)}
>
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{deOderStrich(tank.liters, 2)} l
{tank.fuel_total_eur != null && (
<span className="dm-listenzeile__neben">
{eur(tank.fuel_total_eur)}
</span>
)}
</span>
<span className="dm-listenzeile__unter">
{datum(tank.ts)}
{tank.station_name ? ` · ${tank.station_name}` : ""}
{tank.price_per_l != null ? ` · ${eur(tank.price_per_l)} €/l` : ""}
</span>
</span>
{tank.status === "unvollständig" && <Pill>unvollständig</Pill>}
</button>
}
/>
))}
</Accordion>
)
})}
</Accordion>
))}
</>
)
}
function TankFormular({ beiFertig }: { beiFertig: () => void }) {
const { api } = useDaten()
const [zeitpunkt, setzeZeitpunkt] = useState("")
const [liter, setzeLiter] = useState("")
const [kosten, setzeKosten] = useState("")
const [station, setzeStation] = useState("")
const [km, setzeKm] = useState("")
const [fehler, setzeFehler] = useState<string | null>(null)
const [laeuft, setzeLaeuft] = useState(false)
const speichern = async () => {
setzeFehler(null)
const literZahl = Number(liter.replace(",", "."))
const kostenZahl = Number(kosten.replace(",", "."))
if (!Number.isFinite(literZahl) || literZahl <= 0) {
setzeFehler("Bitte die getankte Menge in Litern angeben.")
return
}
if (!Number.isFinite(kostenZahl) || kostenZahl <= 0) {
setzeFehler("Bitte den Gesamtbetrag angeben.")
return
}
setzeLaeuft(true)
try {
const kmZahl = Number(km.replace(",", "."))
await api.tankvorgangAnlegen({
ts: zeitpunkt ? new Date(zeitpunkt).toISOString() : new Date().toISOString(),
liter: literZahl,
kosten: kostenZahl,
station: station || null,
km: Number.isFinite(kmZahl) && km ? kmZahl : null,
})
beiFertig()
} catch (ursache) {
setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Tankung eintragen</span>
<Feld label="Zeitpunkt">
<input
className="dm-eingabe"
type="datetime-local"
value={zeitpunkt}
onChange={(e) => setzeZeitpunkt(e.target.value)}
/>
</Feld>
<Feld label="Menge" unit="l">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={liter}
onChange={(e) => setzeLiter(e.target.value)}
/>
</Feld>
<Feld label="Gesamtbetrag" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
placeholder="0,00"
value={kosten}
onChange={(e) => setzeKosten(e.target.value)}
/>
</Feld>
<Feld label="Tankstelle">
<input
className="dm-eingabe"
placeholder="optional"
value={station}
onChange={(e) => setzeStation(e.target.value)}
/>
</Feld>
<Feld label="Kilometerstand" unit="km" last>
<input
className="dm-eingabe"
inputMode="numeric"
placeholder="vom Fahrzeug"
value={km}
onChange={(e) => setzeKm(e.target.value)}
/>
</Feld>
{fehler && (
<p className="dm-fehler" role="alert">
{fehler}
</p>
)}
<p className="dm-fussnote">
Den Preis je Liter rechnet die App selbst aus Betrag und Menge er wird nie aus einem Beleg
übernommen. Ohne Kilometerstand nimmt sie den des Fahrzeugs.
</p>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Modell-Typenschild und Vier-Ringe-Zeichen.
*
* Beide wechseln mit dem Erscheinungsbild: auf dunklem Grund die negative,
* auf hellem die positive Fassung. Es gibt nur die Modelle, für die ein
* Typenschild vorliegt — für alles andere zeigt die App nichts statt eines
* falschen Schildes.
*/
import ringeHell from "../assets/audi/audi-rings-white.svg"
import ringeDunkel from "../assets/audi/audi-rings-black.svg"
import { useTheme } from "../theme"
const SCHILDER = import.meta.glob<string>("../assets/audi/typenschilder/*.svg", {
eager: true,
import: "default",
query: "?url",
})
/**
* Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
* darüber hinaus im Namen steht.
*
* Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
* steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
* competition"). Sonst stünde „RS 4" zweimal da.
*/
export function schildErkennen(
titel: string,
): { kuerzel: string; zusatz: string } | null {
const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel)
if (!treffer?.[1]) return null
const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase()
return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() }
}
export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
const [theme] = useTheme()
const erkannt = schildErkennen(modell)
if (!erkannt) return null
const variante = theme === "nacht" ? "negative" : "positive"
const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
if (!quelle) return null
return <img src={quelle} alt={modell} height={hoehe} style={{ height: hoehe, width: "auto" }} />
}
export function VierRinge({ hoehe = 20 }: { hoehe?: number }) {
const [theme] = useTheme()
return (
<img
src={theme === "nacht" ? ringeHell : ringeDunkel}
alt="Audi"
height={hoehe}
style={{ height: hoehe, width: "auto" }}
/>
)
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Übersicht — die Startseite. Vorlage: `vHome()` im alten Panel.
*
* Fahrzeugbild, Status „sicher abgestellt", Reichweite und Tankfüllstand,
* Kilometerstand, nächster Service, Vorschau auf letzte Fahrt und letzte
* Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an.
*/
import { useState } from "react"
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
import { Bild } from "./Bild"
import { Typenschild, schildErkennen } from "./Typenschild"
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
useDaten()
const [aktualisiert, setzeAktualisiert] = useState(false)
if (!fahrzeug || !einstellungen) return null
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
const schild = schildErkennen(einstellungen.fahrzeugtitel)
const ausfuehrung = einstellungen.ausfuehrung.trim()
// Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition"
// plus Ausführung „competition") — dann nicht doppelt anhängen.
const zusatz = [
schild?.zusatz,
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
? ausfuehrung
: "",
]
.filter(Boolean)
.join(" ")
.trim()
const letzteFahrt = fahrten
.slice()
.sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
const letzteTankung = tankvorgaenge
.slice()
.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0]
const sicherheitsstatus =
fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"
const sicherheitstext =
fahrzeug.gesichert === true
? "Sicher abgestellt"
: fahrzeug.gesichert === false
? "Nicht vollständig verschlossen"
: "Zustand unbekannt"
const aktualisieren = async () => {
setzeAktualisiert(true)
try {
await jetztAktualisieren()
} finally {
setzeAktualisiert(false)
}
}
return (
<>
<div className="dm-szene">
<Bild
datei={einstellungen.startbild}
beschreibung={einstellungen.fahrzeugtitel}
name="Fahrzeugbild"
/>
<div className="dm-szene__text">
<div className="dm-szene__marke">
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
Schild tritt der volle Name an dessen Stelle. */}
<div className="ads-title dm-szene__zusatz">
{schild ? (
zusatz && <span className="ads-sport">{zusatz}</span>
) : (
<>
{einstellungen.fahrzeugtitel}{" "}
{zusatz && <span className="ads-sport">{zusatz}</span>}
</>
)}
</div>
</div>
<span className="ads-eyebrow">
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
</span>
</div>
</div>
<StatusRow
status={sicherheitsstatus}
title={sicherheitstext}
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
onClick={() => geheZu("sicherheit")}
/>
<div className="dm-raster">
<Tile>
<span className="ads-eyebrow">Reichweite</span>
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
<div className="dm-tanktext">
<span>{de(fahrzeug.tankPct)} % im Tank</span>
<span>
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
{de(einstellungen.tankvolumen)} l
</span>
</div>
</Tile>
<Tile>
<span className="ads-eyebrow">Kilometerstand</span>
<Fig
value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"}
unit="km"
size={40}
/>
<Werteliste
kinder={
<>
<Wertzeile
label="Nächster Ölwechsel"
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
zusatz={
fahrzeug.oelwechselFaelligKm != null
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
: undefined
}
/>
<Wertzeile
label="Nächste Inspektion"
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
zusatz={
fahrzeug.inspektionFaelligKm != null
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
: undefined
}
/>
</>
}
/>
</Tile>
</div>
<div className="dm-raster">
{letzteFahrt ? (
<NaviKachel
titel="Letzte Fahrt"
wert={
<>
<div className="dm-navikachel__zahl">
{deOderStrich(letzteFahrt.distance_km, 1)} km
</div>
<small>
{datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)}
</small>
</>
}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
/>
) : (
<Tile>
<span className="ads-eyebrow">Letzte Fahrt</span>
<Leerzustand
titel="Noch keine Fahrt erfasst"
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
/>
</Tile>
)}
{letzteTankung ? (
<NaviKachel
titel="Letzte Tankung"
wert={
<>
<div className="dm-navikachel__zahl">
{deOderStrich(letzteTankung.liters, 2)} l
</div>
<small>
{datum(letzteTankung.ts)} ·{" "}
{letzteTankung.fuel_total_eur != null
? `${eur(letzteTankung.fuel_total_eur)}`
: "Betrag offen"}
</small>
</>
}
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
/>
) : (
<Tile>
<span className="ads-eyebrow">Letzte Tankung</span>
<Leerzustand
titel="Noch keine Tankung erfasst"
text="Tankvorgänge erkennt die App automatisch am steigenden Füllstand — oder du trägst sie selbst ein."
/>
</Tile>
)}
</div>
<div className="dm-aktionen">
<button
type="button"
className="dm-textknopf"
onClick={() => void aktualisieren()}
disabled={aktualisiert}
>
{aktualisiert ? "Frage das Fahrzeug …" : "Jetzt beim Fahrzeug nachfragen"}
</button>
</div>
</>
)
}
+404
View File
@@ -0,0 +1,404 @@
/**
* Versicherung, Vertragsdetails, Schutzbrief, Notruf und Kfz-Steuer.
* Vorlagen: `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`,
* `vNotrufBearbeiten()`, `vSteuer()`.
*
* Alle Formulare hier schreiben über `profilSpeichern()` das **ganze** Profil
* zurück — das Backend kennt keinen Dienst für einzelne Felder.
*/
import { useState } from "react"
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { datum, de, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
interface Beitragsposten {
bez?: string
betrag?: number
sf?: string
satz?: string
}
export function Versicherung({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const steuer = fahrzeug.steuer
return (
<>
<Tile>
<span className="ads-eyebrow">Versicherung</span>
<Werteliste
kinder={
<>
<Wertzeile label="Gesellschaft" wert={(v["gesellschaft"] as string) || "—"} />
<Wertzeile label="Vertrag" wert={(v["vertrag"] as string) || "—"} />
<Wertzeile label="Vertragsnummer" wert={(v["nummer"] as string) || "—"} />
<Wertzeile
label="Beitrag"
wert={v["beitrag"] ? `${eur(v["beitrag"] as number)}` : "—"}
zusatz={(v["zahlweise"] as string) || undefined}
/>
<Wertzeile
label="Gültig ab"
wert={v["gueltigAb"] ? datum(v["gueltigAb"] as string) : "—"}
/>
</>
}
/>
</Tile>
<div className="dm-raster">
<NaviKachel titel="Beitrag" wert="Einzelposten bearbeiten" beiKlick={() => geheZu("beitrag")} />
<NaviKachel
titel="Vertragsdetails"
wert="Leistungen und Schutzbrief"
beiKlick={() => geheZu("vertragsdetails")}
/>
<NaviKachel
titel="Notruf"
wert={(v["notfall"] as string) || "Nummern hinterlegen"}
beiKlick={() => geheZu("notruf")}
/>
<NaviKachel
titel="Kfz-Steuer"
wert={steuer["betrag"] ? `${eur(steuer["betrag"] as number)}` : "Noch nicht hinterlegt"}
beiKlick={() => geheZu("steuer")}
/>
</div>
</>
)
}
export function Beitrag() {
const { fahrzeug, profilSpeichern } = useDaten()
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const teile = (v["teile"] ?? []) as Beitragsposten[]
const [entwurf, setzeEntwurf] = useState<Beitragsposten[]>(() => teile.map((t) => ({ ...t })))
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
const summeBetrag = entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
versicherung: { ...v, teile: entwurf, beitrag: summeBetrag },
},
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
if (entwurf.length === 0) {
return (
<Leerzustand
titel="Keine Beitragsposten"
text="Sobald im Profil Einzelposten hinterlegt sind, kannst du sie hier bearbeiten."
/>
)
}
return (
<Tile>
<span className="ads-eyebrow">Beitragsposten</span>
{entwurf.map((posten, index) => (
<Feld
key={`${posten.bez}-${index}`}
label={posten.bez ?? `Posten ${index + 1}`}
unit="€"
last={index === entwurf.length - 1}
>
<input
className="dm-eingabe"
inputMode="decimal"
value={posten.betrag ?? ""}
onChange={(e) =>
setzeEntwurf((alt) =>
alt.map((p, i) =>
i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p,
),
)
}
/>
</Feld>
))}
<Werteliste
kinder={
<Wertzeile
label="Summe"
wert={`${eur(entwurf.reduce((s, t) => s + (Number(t.betrag) || 0), 0))}`}
/>
}
/>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Vertragsdetails({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const v = fahrzeug.versicherung
const leistungen = (v["leistungen"] ?? []) as { bez?: string; wert?: string }[]
const selbst = (v["selbst"] ?? []) as (string | number)[][]
return (
<>
<Tile>
<span className="ads-eyebrow">Leistungen</span>
{leistungen.length === 0 ? (
<p className="dm-fussnote">Im Profil sind noch keine Leistungen hinterlegt.</p>
) : (
<Werteliste
kinder={leistungen.map((l, i) => (
<Wertzeile key={`${l.bez}-${i}`} label={l.bez ?? "—"} wert={l.wert ?? "—"} />
))}
/>
)}
</Tile>
{selbst.length > 0 && (
<Tile>
<span className="ads-eyebrow">Selbstbeteiligung</span>
<Werteliste
kinder={selbst.map((zeile, i) => (
<Wertzeile key={i} label={String(zeile[0] ?? "")} wert={String(zeile[1] ?? "")} />
))}
/>
</Tile>
)}
<NaviKachel
titel="Schutzbrief"
wert="Bedingungen im Detail"
beiKlick={() => geheZu("schutz")}
/>
</>
)
}
export function Schutzbrief() {
const { fahrzeug } = useDaten()
if (!fahrzeug) return null
const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record<string, unknown>
const listen: [string, string[]][] = [
["Auslöser", (s["ausloeser"] ?? []) as string[]],
["Entweder", (s["entweder"] ?? []) as string[]],
["Leistungsgruppen", (s["gruppen"] ?? []) as string[]],
["Nicht versichert", (s["nicht"] ?? []) as string[]],
["Pflichten", (s["pflichten"] ?? []) as string[]],
]
const hatInhalt = listen.some(([, eintraege]) => eintraege.length > 0)
if (!hatInhalt) {
return (
<Leerzustand
titel="Keine Schutzbrief-Angaben"
text="Im Profil sind zum Schutzbrief noch keine Bedingungen hinterlegt."
/>
)
}
return (
<>
<Tile>
<span className="ads-eyebrow">Schutzbrief</span>
<Werteliste
kinder={
<>
<Wertzeile label="Stand" wert={(s["stand"] as string) || "—"} />
<Wertzeile label="Gilt" wert={(s["gilt"] as string) || "—"} />
<Wertzeile label="Versichert" wert={(s["versichert"] as string) || "—"} />
</>
}
/>
</Tile>
{listen
.filter(([, eintraege]) => eintraege.length > 0)
.map(([titel, eintraege]) => (
<Tile key={titel}>
<span className="ads-eyebrow">{titel}</span>
<ul className="dm-liste">
{eintraege.map((eintrag) => (
<li key={eintrag}>{eintrag}</li>
))}
</ul>
</Tile>
))}
</>
)
}
export function Notruf() {
const { fahrzeug, profilSpeichern } = useDaten()
const v = fahrzeug?.versicherung ?? {}
const [service, setzeService] = useState((v["service"] as string) ?? "")
const [notfall, setzeNotfall] = useState((v["notfall"] as string) ?? "")
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, versicherung: { ...v, service, notfall } },
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Nummern im Notfall</span>
<Feld label="Service">
<input
className="dm-eingabe"
type="tel"
value={service}
onChange={(e) => setzeService(e.target.value)}
/>
</Feld>
<Feld label="Notfall" last>
<input
className="dm-eingabe"
type="tel"
value={notfall}
onChange={(e) => setzeNotfall(e.target.value)}
/>
</Feld>
{(service || notfall) && (
<div className="dm-waehlen">
{service && (
<a className="dm-waehlen__knopf" href={`tel:${service.replace(/\s/g, "")}`}>
Service anrufen
</a>
)}
{notfall && (
<a className="dm-waehlen__knopf" href={`tel:${notfall.replace(/\s/g, "")}`}>
Notfall anrufen
</a>
)}
</div>
)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
</Tile>
)
}
export function Steuer() {
const { fahrzeug, profilSpeichern } = useDaten()
const [entwurf, setzeEntwurf] = useState(() => ({ ...(fahrzeug?.steuer ?? {}) }))
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
if (!fahrzeug) return null
const speichern = async () => {
setzeLaeuft(true)
setzeGespeichert(false)
try {
await profilSpeichern({
fahrzeug: { ...fahrzeug, steuer: entwurf as typeof fahrzeug.steuer },
})
setzeGespeichert(true)
} finally {
setzeLaeuft(false)
}
}
return (
<Tile>
<span className="ads-eyebrow">Kfz-Steuer</span>
<Feld label="Betrag" unit="€">
<input
className="dm-eingabe"
inputMode="decimal"
value={(entwurf["betrag"] as number) ?? ""}
onChange={(e) =>
setzeEntwurf((alt) => ({ ...alt, betrag: Number(e.target.value.replace(",", ".")) }))
}
/>
</Feld>
<Feld label="Zeitraum">
<input
className="dm-eingabe"
value={(entwurf["zeitraum"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))}
/>
</Feld>
<Feld label="Fällig am">
<input
className="dm-eingabe"
type="date"
value={(entwurf["faellig"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))}
/>
</Feld>
<Feld label="Finanzamt" last>
<input
className="dm-eingabe"
value={(entwurf["finanzamt"] as string) ?? ""}
onChange={(e) => setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))}
/>
</Feld>
<Werteliste
kinder={
<>
<Wertzeile label="Grundlage" wert={(entwurf["grundlage"] as string) || "—"} />
<Wertzeile
label="Lastschrift"
wert={entwurf["lastschrift"] ? "eingerichtet" : "nicht eingerichtet"}
/>
<Wertzeile
label="Monatlich gerechnet"
wert={
entwurf["betrag"] ? `${eur((entwurf["betrag"] as number) / 12)}` : "—"
}
/>
</>
}
/>
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
</div>
<p className="dm-fussnote">
{de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen.
</p>
</Tile>
)
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Löschweg für Listenzeilen — Wischen **und** ein sichtbares Menü.
*
* Behebt einen offenen Befund des Design-Audits (AUDIT_2026-08-10.md §4.1):
* Im alten Panel war Löschen ausschließlich über eine Wischgeste erreichbar.
* Wer mit Sprachausgabe oder Schaltersteuerung bedient, führt keine
* Wischgesten aus und konnte Fahrten und Tankvorgänge deshalb gar nicht
* löschen. Das Menü ist immer da, ein richtiger Knopf, mit Tastatur erreichbar.
*/
import { useState, type ReactNode } from "react"
import { Popup, PopupMenuItem, SwipeRow } from "@audi-dash/ui"
import { SymbolMehr } from "../symbole"
export function Zeilenmenue({
beiLoeschen,
loeschFrage,
kinder,
}: {
beiLoeschen: () => void
loeschFrage: string
kinder: ReactNode
}) {
const [menueOffen, setzeMenueOffen] = useState(false)
const loeschen = () => {
setzeMenueOffen(false)
if (window.confirm(loeschFrage)) beiLoeschen()
}
return (
<div className="dm-zeile">
<SwipeRow onDelete={loeschen} className="dm-zeile__wisch">
{kinder}
</SwipeRow>
<button
type="button"
className="dm-zeile__menue"
aria-label="Weitere Aktionen"
onClick={() => setzeMenueOffen(true)}
>
<SymbolMehr groesse={18} />
</button>
<Popup open={menueOffen} onClose={() => setzeMenueOffen(false)} variant="menu" anchor="center">
<PopupMenuItem destructive onClick={loeschen}>
Löschen
</PopupMenuItem>
<PopupMenuItem onClick={() => setzeMenueOffen(false)}>Abbrechen</PopupMenuItem>
</Popup>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Kleine, über mehrere Seiten geteilte Bausteine. Alles, was mehr als einmal
* gebraucht wird, aber zu speziell für das Design-System ist.
*/
import type { ReactNode } from "react"
import { ActionButton, Tile } from "@audi-dash/ui"
/**
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
* Hinweis, was als Nächstes passiert — nie nur „keine Daten".
* Wird real gebraucht: Fahrten und Tankvorgänge sind zu Beginn leer.
*/
export function Leerzustand({
titel,
text,
aktion,
}: {
titel: string
text: string
aktion?: { beschriftung: string; beiKlick: () => void }
}) {
return (
<div className="dm-leer">
<div className="dm-leer__titel">{titel}</div>
<p className="dm-leer__text">{text}</p>
{aktion && (
<div style={{ marginTop: 8 }}>
<ActionButton onClick={aktion.beiKlick}>{aktion.beschriftung}</ActionButton>
</div>
)}
</div>
)
}
/** Abschnittsüberschrift zwischen Kacheln. */
export function Abschnitt({ titel, kinder }: { titel: string; kinder?: ReactNode }) {
return (
<div className="dm-abschnitt">
<span className="ads-eyebrow">{titel}</span>
{kinder}
</div>
)
}
/** Kachel, die zu einer Unterseite führt. */
export function NaviKachel({
titel,
wert,
beiKlick,
}: {
titel: string
wert?: ReactNode
beiKlick: () => void
}) {
return (
<Tile variant="button" chevron onClick={beiKlick}>
<span className="ads-eyebrow">{titel}</span>
{wert !== undefined && <div className="dm-navikachel__wert">{wert}</div>}
</Tile>
)
}
/**
* Ein Wert mit Beschriftung. „—" statt eines erfundenen Werts, wenn nichts
* bekannt ist — die Regel zieht sich durch die ganze App.
*/
export function Wertzeile({
label,
wert,
zusatz,
}: {
label: ReactNode
wert: ReactNode
zusatz?: ReactNode
}) {
return (
<div className="dm-wertzeile">
<dt>{label}</dt>
<dd>
{wert}
{zusatz && <small>{zusatz}</small>}
</dd>
</div>
)
}
export function Werteliste({ kinder }: { kinder: ReactNode }) {
return <dl className="dm-werteliste">{kinder}</dl>
}
/** Bestätigung vor unwiderruflichen Aktionen. */
export function bestaetigen(frage: string): boolean {
return window.confirm(frage)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Fahrzeugfotos. Die Dateinamen sind fest: `bilderverwaltung.py` prüft jeden
* Upload gegen eine Positivliste und lehnt alles andere ab — hier steht
* dieselbe Liste, damit die Oberfläche gar nicht erst etwas anbieten kann,
* das der Server ablehnen würde.
*/
import { zugangLesenSynchron } from "./zugang"
export interface Bildplatz {
name: string
beschreibung: string
datei: string
}
/** Die sieben Plätze der Fotogalerie unter „Mein Audi". */
export const BILDPLAETZE: Bildplatz[] = [
{ name: "Seitenansicht", beschreibung: "Auto von der Seite", datei: "seitenansicht.webp" },
{ name: "Front schräg", beschreibung: "Auto von vorn schräg", datei: "front-schraeg.webp" },
{ name: "Frontansicht", beschreibung: "Auto von vorn", datei: "frontansicht.webp" },
{ name: "Heckansicht", beschreibung: "Auto von hinten", datei: "heckansicht.webp" },
{ name: "Cockpit", beschreibung: "Innenraum Cockpit", datei: "cockpit.webp" },
{ name: "Scheinwerfer", beschreibung: "Scheinwerfer", datei: "scheinwerfer.webp" },
{ name: "Sitze", beschreibung: "Innenraum Sitze", datei: "sitze.webp" },
]
/** Zusätzlich erlaubt, aber außerhalb der Galerie geführt. */
export const WEITERE_BILDER = [
"seitenansicht-winter.webp",
"rad-sommer.webp",
"rad-winter.webp",
] as const
export const ERLAUBTE_DATEINAMEN: readonly string[] = [
...BILDPLAETZE.map((b) => b.datei),
...WEITERE_BILDER,
]
/**
* Adresse eines Fahrzeugfotos auf dem Server. Die Bilder liegen unter
* `/local/bilder/` — anders als beim alten Panel läuft die App nicht
* zwangsläufig auf derselben Adresse, deshalb die vollständige URL.
*/
export function bildUrl(dateiname: string | null | undefined): string | undefined {
if (!dateiname) return undefined
const basis = zugangLesenSynchron()?.basisUrl
if (!basis) return undefined
return `${basis}/local/bilder/${dateiname}`
}
+33
View File
@@ -0,0 +1,33 @@
/**
* CSV-Ausgabe. Portiert aus dem alten Panel: Semikolon als Trennzeichen,
* deutsches Zahlenformat und ein BOM am Anfang — nur damit öffnet Excel die
* Datei ohne Rückfragen richtig.
*/
function feld(wert: unknown): string {
if (wert === null || wert === undefined) return ""
if (typeof wert === "number") return String(wert).replace(".", ",")
const text = String(wert)
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
export function alsCsv(kopf: readonly string[], zeilen: readonly unknown[][]): string {
const inhalt = [kopf.join(";"), ...zeilen.map((z) => z.map(feld).join(";"))].join("\r\n")
return `${inhalt}`
}
export function csvHerunterladen(
name: string,
kopf: readonly string[],
zeilen: readonly unknown[][],
): void {
const blob = new Blob([alsCsv(kopf, zeilen)], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `${name}.csv`
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
URL.revokeObjectURL(url)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
*
* Erzeugt eine .ics-Datei im Browser, ohne Server — sie landet als Download
* bzw. auf dem iPhone direkt im Kalender. Mit Erinnerung zwei Tage vorher.
*/
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
function alsIcsDatum(isoTag: string): string {
return isoTag.replace(/-/g, "")
}
export function kalenderDatei(titel: string, isoTag: string, beschreibung = ""): string {
const stempel = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const start = alsIcsDatum(isoTag)
const ende = alsIcsDatum(
new Date(new Date(isoTag).getTime() + 86_400_000).toISOString().slice(0, 10),
)
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//DataMetric360//DE",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:dm360-${start}-${Math.abs(hash(titel))}@datametric360`,
`DTSTAMP:${stempel}`,
`DTSTART;VALUE=DATE:${start}`,
`DTEND;VALUE=DATE:${ende}`,
`SUMMARY:${zeilenUmbruch(titel)}`,
beschreibung ? `DESCRIPTION:${zeilenUmbruch(beschreibung)}` : "",
"BEGIN:VALARM",
"TRIGGER:-P2D",
"ACTION:DISPLAY",
`DESCRIPTION:${zeilenUmbruch(titel)}`,
"END:VALARM",
"END:VEVENT",
"END:VCALENDAR",
]
.filter(Boolean)
.join("\r\n")
}
function hash(text: string): number {
let wert = 0
for (let i = 0; i < text.length; i++) wert = (wert * 31 + text.charCodeAt(i)) | 0
return wert
}
export function kalenderDateiLaden(titel: string, isoTag: string, beschreibung = ""): void {
const inhalt = kalenderDatei(titel, isoTag, beschreibung)
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = `${titel.replace(/[^\wäöüÄÖÜß-]+/g, "-").toLowerCase()}.ics`
document.body.appendChild(verweis)
verweis.click()
verweis.remove()
URL.revokeObjectURL(url)
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Zuordnung Route → Seite. Eine Stelle, an der jede Seite eingetragen ist;
* die Shell weiß nichts über Inhalte, die Seiten nichts über Navigation.
*/
import { useDaten } from "../daten/DatenKontext"
import type { Route, SeitenName } from "../navigation"
import { Batterie } from "./Batterie"
import { Einstellungen } from "./Einstellungen"
import { FahrtDetail } from "./FahrtDetail"
import { Fahrten } from "./Fahrten"
import { Fahrzeugdaten } from "./Fahrzeugdaten"
import { Fahrzeugstatus } from "./Fahrzeugstatus"
import { LiveFahrt } from "./LiveFahrt"
import { MeinAudi } from "./MeinAudi"
import { Reifen } from "./Reifen"
import { Service, Servicebuch, Werkstatt } from "./Service"
import { Statistik } from "./Statistik"
import { TankDetail } from "./TankDetail"
import { Tanken } from "./Tanken"
import { Uebersicht } from "./Uebersicht"
import {
Beitrag,
Notruf,
Schutzbrief,
Steuer,
Versicherung,
Vertragsdetails,
} from "./Versicherung"
export interface SeitenProps {
route: Route
geheZu: (name: SeitenName, id?: string) => void
tabBeschriftung: boolean
setzeTabBeschriftung: (an: boolean) => void
beiAbmeldung: () => void
}
export function SeiteFuer(props: SeitenProps) {
const daten = useDaten()
// Erste Ladung: nichts anzeigen außer einem Hinweis. Ein leeres Gerüst wäre
// schlimmer als ein ehrliches „lädt".
if (!daten.bereit) {
if (daten.ladefehler) {
return (
<div className="dm-leer">
<div className="dm-leer__titel">Daten nicht erreichbar</div>
<p className="dm-leer__text">{daten.ladefehler}</p>
</div>
)
}
return <div className="dm-laden">Daten werden geladen </div>
}
const { route, geheZu, tabBeschriftung, setzeTabBeschriftung, beiAbmeldung } = props
switch (route.name) {
case "home":
return <Uebersicht geheZu={geheZu} />
case "sicherheit":
return <Fahrzeugstatus />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "ident":
return <Fahrzeugdaten />
case "battverlauf":
return <Batterie />
case "service":
return <Service geheZu={geheZu} />
case "werkstatt":
return <Werkstatt />
case "sbuch":
return <Servicebuch />
case "reifen":
return <Reifen />
case "vers":
return <Versicherung geheZu={geheZu} />
case "beitrag":
return <Beitrag />
case "vertragsdetails":
return <Vertragsdetails geheZu={geheZu} />
case "schutz":
return <Schutzbrief />
case "notruf":
return <Notruf />
case "steuer":
return <Steuer />
case "trips":
return <Fahrten geheZu={geheZu} />
case "trip":
return <FahrtDetail id={route.id} />
case "fuel":
return <Tanken geheZu={geheZu} />
case "fill":
return <TankDetail id={route.id} />
case "stat":
return <Statistik />
case "live":
return <LiveFahrt />
case "einst":
return (
<Einstellungen
tabBeschriftung={tabBeschriftung}
setzeTabBeschriftung={setzeTabBeschriftung}
beiAbmeldung={beiAbmeldung}
/>
)
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Abnahme für Phase 7 des Umsetzungsplans: Jede Seite muss mit echten
* Beispieldaten rendern — in beiden Layouts — und die Leerzustände müssen
* greifen, statt dass eine leere Liste die Seite zerlegt.
*/
import { render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { DatenAnbieter } from "../daten/DatenKontext"
import { Shell } from "../Shell"
import type { SeitenName } from "../navigation"
import { TITEL } from "../navigation"
import { SeiteFuer } from "./register"
import { beispielApi } from "../tests/beispieldaten"
import { bildschirmbreiteSetzen } from "../tests/aufbau"
const ALLE_SEITEN = Object.keys(TITEL) as SeitenName[]
function zeige(name: SeitenName, id?: string) {
const api = beispielApi()
return render(
<div className="ads-root" data-theme="nacht">
<DatenAnbieter api={api}>
<Shell
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
tabBeschriftung
>
<SeiteFuer
route={id === undefined ? { name } : { name, id }}
geheZu={() => {}}
tabBeschriftung
setzeTabBeschriftung={() => {}}
beiAbmeldung={() => {}}
/>
</Shell>
</DatenAnbieter>
</div>,
)
}
describe("Alle Seiten rendern mit Beispieldaten", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
for (const name of ALLE_SEITEN) {
it(`${name} (${TITEL[name]})`, async () => {
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
const { container } = zeige(name, id)
// Nach der Erstladung darf kein Ladehinweis mehr stehen.
await waitFor(() => {
expect(container.querySelector(".dm-laden")).toBeNull()
})
expect(container.textContent?.length ?? 0).toBeGreaterThan(20)
})
}
it("rendert jede Seite auch im Flächenlayout", async () => {
bildschirmbreiteSetzen(1280)
for (const name of ALLE_SEITEN) {
const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
const { container, unmount } = zeige(name, id)
await waitFor(() => {
expect(container.querySelector(".dm-laden")).toBeNull()
})
expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
unmount()
}
})
})
describe("Inhalte kommen wirklich aus den Daten", () => {
beforeEach(() => {
bildschirmbreiteSetzen(390)
})
it("zeigt Kilometerstand und Reichweite auf der Übersicht", async () => {
zeige("home")
await waitFor(() => expect(screen.getByText("48.250")).toBeTruthy())
expect(screen.getByText("385")).toBeTruthy()
})
it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
const { container } = zeige("sicherheit")
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
expect(screen.getByText("unbekannt")).toBeTruthy()
})
it("gruppiert Fahrten nach Jahr", async () => {
zeige("trips")
await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
expect(screen.getByText("42,5 km")).toBeTruthy()
})
it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
const { container } = zeige("trip", "t1")
await waitFor(() => expect(screen.getByText("Keine Route aufgezeichnet")).toBeTruthy())
expect(container.querySelector(".dm-karte")).toBeNull()
})
it("rechnet den Tankvorgang mit mengengewichtetem Preis", async () => {
zeige("fill", "f1")
await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
expect(screen.getByText("1,83 €")).toBeTruthy()
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Synchroner Zugriff auf die Basis-Adresse.
*
* Die Datenschicht liest Zugangsdaten bewusst asynchron (die native Ablage
* unter iOS/Android ist es auch). Für Bild-URLs im Rendern wird die Adresse
* aber synchron gebraucht — deshalb wird sie einmal beim Start gemerkt.
*/
import { zugangLesen, type Zugang } from "../api"
let gemerkt: Zugang | null = null
export async function zugangMerken(): Promise<void> {
gemerkt = await zugangLesen()
}
export function zugangLesenSynchron(): Zugang | null {
return gemerkt
}
export function zugangVergessen(): void {
gemerkt = null
}
+63
View File
@@ -0,0 +1,63 @@
/*
* Hausschrift Audi Type.
*
* RECHTLICH: Schrift, Ringe und Typenschilder sind lizenz- beziehungsweise
* markenrechtlich geschützt und ausschließlich für diese eine private,
* nicht veröffentlichte Installation freigegeben (bauauftrag.md §8/§12).
* Deshalb wird die App nur seitlich installiert und nie in einen App Store
* gegeben — und deshalb enthält design-system/ diese Dateien bewusst nicht,
* weil es nach außen hochgeladen wird.
*
* Die Dateien stammen aus homeassistant/www/audi-dashboard.css, wo sie als
* base64 eingebettet lagen. Als eigene woff2-Dateien sind sie zwischenspeicherbar,
* statt bei jedem Laden des Stylesheets erneut übertragen zu werden.
*
* Es gibt nur die Schnitte 300 und 400. Niemals fetter setzen — der Browser
* würde die Schrift künstlich verdicken, was sichtbar anders aussieht.
*/
@font-face {
font-family: "Audi Type";
src: url("../assets/audi/schriften/audi-type-400.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Audi Type Wide";
src: url("../assets/audi/schriften/audi-type-wide-300.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Audi Type Extended";
src: url("../assets/audi/schriften/audi-type-extended-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/*
* Überschreibt den neutralen Ersatzstapel aus @audi-dash/ui. Die Bibliothek
* selbst bleibt unberührt — sie kennt diese Schrift nicht und darf es nicht.
*/
.ads-root {
--font-stack: "Audi Type", Helvetica, Arial, sans-serif;
--font-wide: "Audi Type Wide", "Audi Type", Helvetica, Arial, sans-serif;
}
/* Große Zahlen (Kilometerstand, Reichweite, Beträge) in der breiten Schnitt-
variante, wie im Panel. */
.ads-fig,
.dm-detail__zahl {
font-family: var(--font-wide);
font-weight: 300;
}
.ads-sport {
font-family: "Audi Type Extended", var(--font-stack);
font-style: italic;
}
+78
View File
@@ -0,0 +1,78 @@
/*
* Grundlayout der App. Alles Gestalterische kommt aus @audi-dash/ui
* (Tokens + Komponenten) — hier steht nur, was eine App braucht und eine
* Komponentenbibliothek nicht mitbringen kann: Vollbild, sichere Bereiche,
* Scrollverhalten, Fokusring.
*/
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overscroll-behavior-y: none;
}
body {
background: var(--canvas);
}
#wurzel {
height: 100%;
}
/* Wurzel der App: setzt Schrift, Farben und Zahlendarstellung (.ads-root aus
der Bibliothek) und spannt sich über die volle Höhe. */
.dm-wurzel {
height: 100%;
display: flex;
overflow: hidden;
font-weight: 400;
}
/* Tastaturbedienung: im alten Panel als Audit-Befund nachgerüstet, hier von
Anfang an. :focus-visible statt :focus lässt Maus und Touch unberührt. */
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
.dm-scroll {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.dm-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dm-scroll::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: var(--r-pill);
}
/* Formularfelder: mindestens 16px, sonst zoomt iOS Safari beim Fokus die
ganze Seite (Audit-Befund aus dem alten Panel). */
input,
select,
textarea,
button {
font-family: inherit;
font-size: 16px;
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+209
View File
@@ -0,0 +1,209 @@
/*
* Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche.
* Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui.
*/
.dm-spalte {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
/* ------------------------------------------------------------- Kopfzeile */
.dm-kopf {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
border-bottom: 1px solid var(--line);
background: var(--canvas);
}
.dm-kopf__titel {
flex: 1;
min-width: 0;
}
.dm-kopf__zurueck,
.dm-kopf__aktion {
flex: none;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--fg2);
cursor: pointer;
}
.dm-kopf__zurueck:hover,
.dm-kopf__aktion:hover {
background: var(--shade);
color: var(--fg);
}
.dm-kopf__aktion[aria-current="page"] {
color: var(--red);
}
/* ---------------------------------------------------------- Inhaltsfläche */
.dm-inhalt {
flex: 1;
padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0,
sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt
die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst.
Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */
.dm-inhalt > * {
flex: none;
}
.dm-inhalt--breit {
padding: var(--sp-5) 32px 32px;
}
/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt
untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */
.dm-raster {
display: grid;
gap: var(--sp-3);
grid-template-columns: 1fr;
}
@media (min-width: 1000px) {
.dm-raster {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.dm-raster--drei {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dm-raster__breit {
grid-column: 1 / -1;
}
}
/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */
.dm-zweispalter {
display: grid;
gap: var(--sp-4);
grid-template-columns: 1fr;
align-items: start;
}
@media (min-width: 1000px) {
.dm-zweispalter {
grid-template-columns: minmax(0, 4fr) minmax(0, 5fr);
}
}
/* ----------------------------------------------------------- Seitenleiste */
.dm-seitenleiste {
flex: 0 0 240px;
width: 240px;
height: 100%;
box-sizing: border-box;
border-right: 1px solid var(--line);
padding: 24px 14px 16px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--canvas);
}
.dm-seitenleiste__marke {
padding: 0 10px var(--sp-4);
}
.dm-seitenleiste__punkt {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: 11px 10px;
border: 0;
border-radius: var(--r-func);
background: transparent;
color: var(--fg2);
font-size: 14px;
font-weight: 300;
text-align: left;
cursor: pointer;
}
.dm-seitenleiste__punkt:hover {
background: var(--shade);
color: var(--fg);
}
.dm-seitenleiste__punkt--an {
background: var(--tile);
color: var(--fg);
}
.dm-seitenleiste__fuss {
margin-top: auto;
padding-top: var(--sp-3);
border-top: 1px solid var(--line);
}
/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als
Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */
.dm-brotkrume {
padding: var(--sp-4) 32px 0;
}
.dm-brotkrume button {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: var(--fg2);
font-size: 13px;
font-weight: 300;
cursor: pointer;
padding: 6px 0;
}
.dm-brotkrume button:hover {
color: var(--fg);
}
/* ------------------------------------------------------------ Tab-Leiste */
.dm-tableiste {
flex: none;
padding-bottom: env(safe-area-inset-bottom, 0px);
border-top: 1px solid var(--line);
background: var(--canvas);
}
.dm-seitenleiste__marke {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 10px var(--sp-4);
}
.dm-szene__marke {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
}
+595
View File
@@ -0,0 +1,595 @@
/*
* Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste,
* Ersteinrichtung, Leerzustände, Ladeanzeige.
*/
/* -------------------------------------------------------- Eingabefelder */
.dm-eingabe,
.dm-auswahl {
font: inherit;
font-weight: 300;
font-size: 16px;
color: var(--fg);
background: none;
border: 0;
text-align: right;
outline: none;
min-width: 120px;
max-width: 60%;
padding: 0;
}
.dm-eingabe::placeholder {
color: var(--fg3);
}
.dm-auswahl {
text-align: right;
appearance: none;
cursor: pointer;
}
.dm-auswahl option {
color: var(--fg);
background: var(--tile);
}
.dm-notiz {
font: inherit;
font-weight: 300;
font-size: 16px;
color: var(--fg);
background: var(--tile-2);
border: 0;
border-radius: var(--r-func);
padding: 10px 12px;
width: 100%;
min-height: 88px;
resize: vertical;
outline: none;
}
/* --------------------------------------------------------- Hinweisleiste */
.dm-hinweis {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 9px var(--sp-5);
padding-top: calc(env(safe-area-inset-top, 0px) + 9px);
font-size: 12.5px;
font-weight: 300;
color: var(--fg);
background: var(--tile-2);
border-bottom: 1px solid var(--line);
}
.dm-hinweis--offline {
color: var(--warn);
}
.dm-hinweis svg {
flex: none;
}
/* -------------------------------------------------------- Ersteinrichtung */
.dm-einrichtung {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
overflow-y: auto;
}
.dm-einrichtung__karte {
width: 100%;
max-width: 420px;
}
.dm-einrichtung__titel {
font-size: 28px;
font-weight: 300;
letter-spacing: -0.02em;
margin: 10px 0 12px;
}
.dm-einrichtung__text {
font-size: 14px;
font-weight: 300;
line-height: 1.55;
color: var(--fg2);
margin: 0 0 22px;
text-wrap: pretty;
}
.dm-einrichtung__fehler {
font-size: 13px;
font-weight: 300;
line-height: 1.5;
color: var(--bad);
margin: 14px 0 0;
}
.dm-einrichtung__knoepfe {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: var(--sp-4);
}
.dm-einrichtung__hinweis {
font-size: 11.5px;
font-weight: 300;
line-height: 1.5;
color: var(--fg3);
margin: 18px 0 0;
}
/* ------------------------------------------------- Leer- und Ladezustände */
.dm-leer {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--sp-2);
padding: 44px 24px;
color: var(--fg2);
}
.dm-leer__titel {
font-size: 16px;
font-weight: 300;
color: var(--fg);
}
.dm-leer__text {
font-size: 13.5px;
font-weight: 300;
line-height: 1.55;
max-width: 34ch;
margin: 0;
text-wrap: pretty;
}
.dm-laden {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 44px 24px;
color: var(--fg2);
font-size: 13.5px;
font-weight: 300;
}
/* ------------------------------------------------------------ Übersicht */
.dm-szene {
position: relative;
border-radius: var(--r-tile);
overflow: hidden;
background: var(--tile);
}
.dm-szene .ads-bild,
.dm-szene img {
width: 100%;
display: block;
}
.dm-szene__text {
padding: var(--sp-4) var(--sp-4) var(--sp-5);
}
.dm-tanktext {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-2);
font-size: 12.5px;
font-weight: 300;
color: var(--fg2);
}
.dm-navikachel__wert {
margin-top: var(--sp-1);
font-size: 15px;
font-weight: 300;
color: var(--fg);
}
.dm-navikachel__zahl {
font-size: 20px;
font-weight: 300;
}
.dm-navikachel__wert small,
.dm-wertzeile small {
display: block;
margin-top: 2px;
font-size: 11.5px;
color: var(--fg3);
}
.dm-aktionen {
display: flex;
justify-content: center;
padding: var(--sp-2) 0 var(--sp-4);
}
.dm-textknopf {
border: 0;
background: transparent;
color: var(--fg2);
font-size: 13px;
font-weight: 300;
padding: 10px 14px;
border-radius: var(--r-pill);
cursor: pointer;
}
.dm-textknopf:hover:not(:disabled) {
background: var(--shade);
color: var(--fg);
}
.dm-textknopf:disabled {
opacity: 0.5;
cursor: default;
}
/* --------------------------------------------------------- Werteliste */
.dm-werteliste {
margin: var(--sp-3) 0 0;
}
.dm-wertzeile {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.dm-wertzeile:last-child {
border-bottom: 0;
}
.dm-wertzeile dt {
font-size: 13px;
font-weight: 300;
color: var(--fg2);
}
.dm-wertzeile dd {
margin: 0;
font-size: 14px;
font-weight: 300;
text-align: right;
}
.dm-abschnitt {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) 2px 0;
}
.dm-fussnote {
margin: var(--sp-3) 0 0;
font-size: 11.5px;
font-weight: 300;
line-height: 1.5;
color: var(--fg3);
text-wrap: pretty;
}
/* ------------------------------------------------------ Fahrzeugstatus */
.dm-pruefliste {
list-style: none;
margin: var(--sp-3) 0 0;
padding: 0;
}
.dm-pruefliste__zeile {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.dm-pruefliste__zeile:last-child {
border-bottom: 0;
}
.dm-pruefliste__label {
flex: 1;
font-size: 14px;
font-weight: 300;
}
.dm-pruefliste__wert {
font-size: 12.5px;
font-weight: 300;
color: var(--fg2);
}
.dm-punkt {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
}
.dm-punkt--ok {
background: var(--ok);
}
.dm-punkt--offen {
background: var(--bad);
}
/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot,
damit "keine Meldung" nie wie eine Aussage aussieht. */
.dm-punkt--unbekannt {
border: 1.5px solid var(--fg3);
}
/* ------------------------------------------------------------ Galerie */
.dm-galerie {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: var(--sp-2);
margin-top: var(--sp-3);
}
/* ------------------------------------------------------- Listenzeilen */
.dm-zeile {
display: flex;
align-items: stretch;
gap: 2px;
}
.dm-zeile__wisch {
flex: 1;
min-width: 0;
}
/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn
waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
.dm-zeile__menue {
flex: none;
width: 44px;
border: 0;
background: transparent;
color: var(--fg3);
border-radius: var(--r-func);
cursor: pointer;
}
.dm-zeile__menue:hover {
background: var(--shade);
color: var(--fg);
}
.dm-listenzeile {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: 12px 2px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.dm-listenzeile__haupt {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.dm-listenzeile__titel {
display: flex;
align-items: baseline;
gap: var(--sp-2);
font-size: 16px;
font-weight: 300;
}
.dm-listenzeile__neben {
font-size: 13px;
color: var(--fg2);
}
.dm-listenzeile__unter {
font-size: 12px;
font-weight: 300;
color: var(--fg3);
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
die man sucht. */
line-height: 1.4;
}
/* -------------------------------------------------------- Detailseiten */
.dm-detail__kopf {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
margin: var(--sp-2) 0 var(--sp-3);
flex-wrap: wrap;
}
.dm-detail__zahl {
font-size: 40px;
font-weight: 300;
line-height: 1;
letter-spacing: -0.02em;
}
.dm-detail__einheit {
font-size: 15px;
color: var(--fg2);
margin-left: 6px;
}
.dm-detail__marken {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dm-karte {
height: 240px;
width: 100%;
border-radius: var(--r-func);
overflow: hidden;
margin-top: var(--sp-3);
background: var(--tile-2);
}
/* ------------------------------------------------------------ Meldungen */
.dm-fehler {
margin: var(--sp-3) 0 0;
font-size: 13px;
font-weight: 300;
line-height: 1.5;
color: var(--bad);
}
.dm-erfolg {
margin: var(--sp-3) 0 0;
font-size: 13px;
font-weight: 300;
line-height: 1.5;
color: var(--ok);
}
.dm-fussnote--warnung {
color: var(--warn);
}
/* --------------------------------------------------------- Listen, Text */
.dm-liste {
list-style: none;
margin: var(--sp-2) 0 0;
padding: 0;
font-size: 13.5px;
font-weight: 300;
line-height: 1.7;
}
.dm-liste li {
padding-left: 14px;
position: relative;
}
.dm-liste li::before {
content: "";
position: absolute;
left: 0;
top: 0.72em;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fg3);
}
.dm-liste__serie {
color: var(--fg2);
}
.dm-diagramm {
width: 100%;
height: 120px;
margin: var(--sp-3) 0;
display: block;
}
.dm-knopfreihe {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.dm-waehlen {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.dm-waehlen__knopf {
display: inline-flex;
align-items: center;
padding: 10px 16px;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
font-size: 13.5px;
font-weight: 300;
color: var(--fg);
text-decoration: none;
}
.dm-waehlen__knopf:hover {
background: var(--shade);
}
/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht
aus dem Blick geraten. */
.dm-speicherleiste {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) 0;
background: var(--canvas);
border-top: 1px solid var(--line);
}
.dm-szene__zusatz {
min-width: 0;
}
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
.dm-szene__zusatz {
min-width: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
font-size: 19px;
line-height: 1.2;
}
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung
innerhalb des Rahmens bleibt. */
.dm-galerie {
grid-template-columns: repeat(auto-fill, 78px);
justify-content: space-between;
gap: var(--sp-3);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Symbole als React-Bausteine. Aus dem alten Panel übernommen (dort als
* SVG-Fragmente in Zeichenketten), damit App und Panel identisch aussehen.
* Bewusst keine Symbolbibliothek: es sind eine Handvoll, und jede Bibliothek
* wäre größer als der gesamte Rest.
*/
import type { ReactElement } from "react"
// Ausdrücklich `| undefined`, weil exactOptionalPropertyTypes eingeschaltet ist:
// ein durchgereichtes `groesse={undefined}` muss zulässig bleiben.
interface SymbolProps {
groesse?: number | undefined
}
function Rahmen({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) {
return (
<svg
width={groesse}
height={groesse}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{kinder}
</svg>
)
}
export function SymbolUebersicht({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M4 17a9 9 0 0 1 18 0" />
<path d="M13 17l5.5-5" />
<circle cx="13" cy="17" r="1.3" />
</g>
}
/>
)
}
export function SymbolAudi({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />}
/>
)
}
export function SymbolFahrten({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="6" cy="6" r="2.2" />
<circle cx="18" cy="18" r="2.2" />
<path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5" />
</g>
}
/>
)
}
export function SymbolStatistik({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2" />} />
}
export function SymbolTanken({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20" />
<path d="M3 20h12.5" />
<path d="M4.5 11h10.5" />
<path d="M17 20v-8.5a2 2 0 0 0-2-2" />
<path d="M17 11.5V7.8l-2-2.3" />
</g>
}
/>
)
}
export function SymbolZurueck({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M15 5l-7 7 7 7" />} />
}
export function SymbolZahnrad({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" />
</g>
}
/>
)
}
export function SymbolSonne({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.7 4.7l1.7 1.7M17.6 17.6l1.7 1.7M19.3 4.7l-1.7 1.7M6.4 17.6l-1.7 1.7" />
</g>
}
/>
)
}
export function SymbolMond({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={<path d="M19.5 14.4A8.4 8.4 0 1 1 9.6 4.5a6.7 6.7 0 0 0 9.9 9.9z" />}
/>
)
}
export function SymbolMehr({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</g>
}
/>
)
}
export function SymbolWarnung({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
kinder={
<g>
<path d="M12 4.5 2.5 20h19z" />
<path d="M12 10v4.5" />
<circle cx="12" cy="17.3" r="0.9" fill="currentColor" stroke="none" />
</g>
}
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Testaufbau. jsdom kennt matchMedia nicht — die App fragt es aber ab, um
* zwischen Handy- und Flächenlayout zu wählen. `bildschirmbreiteSetzen()`
* stellt eine steuerbare Fassung bereit.
*/
let aktuelleBreite = 390
export function bildschirmbreiteSetzen(breite: number): void {
aktuelleBreite = breite
}
function passt(abfrage: string): boolean {
const min = /\(min-width:\s*(\d+)px\)/.exec(abfrage)
if (min?.[1]) return aktuelleBreite >= Number(min[1])
const max = /\(max-width:\s*(\d+)px\)/.exec(abfrage)
if (max?.[1]) return aktuelleBreite <= Number(max[1])
if (abfrage.includes("prefers-color-scheme: light")) return false
return false
}
window.matchMedia = ((abfrage: string) => ({
matches: passt(abfrage),
media: abfrage,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
+213
View File
@@ -0,0 +1,213 @@
/**
* Beispieldaten für Rendertests. Aufgebaut wie das, was das Backend
* tatsächlich liefert (an der laufenden Testinstanz abgelesen), nicht wie es
* laut Spezifikation aussehen sollte.
*/
import type { DataMetricApi, Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "../api"
export const beispielStatus: Fahrzeugstatus = {
km: 48250,
tankprozent: 62,
reichweite_km: 385,
batteriespannung: null,
gesichert: true,
sicherheitscheck: [
{ label: "Tür vorne links", ok: true },
{ label: "Tür vorne rechts", ok: true },
{ label: "Fenster vorne links", ok: false },
{ label: "Motorhaube", ok: null },
],
oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00",
oelwechsel_faellig_km: 11750,
inspektion_faellig_ts: "2027-03-14T00:00:00+00:00",
inspektion_faellig_km: 11750,
}
export const beispielProfil: Profil = {
fahrzeug: {
fin: "WUAZZZF48PA902804",
titel: "RS 4 Avant",
zusatz: "competition",
untertitel: "quattro · 331 kW",
details: "2.9 TFSI quattro competition",
kennzeichen: "M AB 1234",
erstzulassung: "2023-04-01",
hauptuntersuchung_faellig: "2027-04-01",
tankvolumen_liter: 58,
wlan_name: "Audi_MMI",
ausfuehrung: "competition",
},
einstellungen: {
nacht_von: "22:00",
nacht_bis: "06:00",
fahrten_pausenzeit_min: 15,
uebersichtsbild: "seitenansicht.webp",
kraftstoffanbieter: "Shell",
smartdeal: { aktiv: true, laeuft_ab: null },
oelwechsel_intervall: {
modus: "hersteller",
km: 15000,
monate: 12,
hersteller_km: 30000,
hersteller_monate: 24,
},
backup_intervall: "taeglich",
letztes_backup: "2026-08-10T04:00:00",
},
versicherung: {
gesellschaft: "Beispiel Versicherung",
vertrag: "Vollkasko",
nummer: "V-123456",
service: "0800 111111",
notfall: "0800 222222",
beitrag: 840,
zahlweise: "jährlich",
gueltigAb: "2026-01-01",
teile: [
{ bez: "Haftpflicht", betrag: 320 },
{ bez: "Vollkasko", betrag: 520 },
],
selbst: [["Vollkasko", "500 €"]],
leistungen: [{ bez: "Werkstattbindung", wert: "nein" }],
schutzbrief: {
stand: "2026-01",
gilt: "Europa",
versichert: "Fahrzeug und Insassen",
ausloeser: ["Panne", "Unfall"],
entweder: [],
gruppen: ["Abschleppen"],
nicht: [],
pflichten: ["Unverzüglich melden"],
},
},
steuer: {
betrag: 320,
zeitraum: "jährlich",
faellig: "2027-04-01",
grundlage: "Hubraum und CO₂-Ausstoß",
lastschrift: true,
finanzamt: "München",
},
reifen: {
aktiv: "sommer",
anzugsmoment_nm: 140,
wechsel: { datum: "2026-10-15", vereinbart: false },
referenz_odo_km: 48250,
saetze: {
sommer: { km: 12000, mass: "275/30 R20", marke: "Michelin", druck_vorne: "2,5", druck_hinten: "2,4" },
winter: { km: 8000, mass: "265/35 R19", marke: "Continental", druck_vorne: "2,6", druck_hinten: "2,5" },
},
},
service: {
vereinbart: {},
autohaus: { name: "Audi Zentrum", adresse: "Musterweg 1", tel: "089 1234", mail: "a@b.de" },
buch: [
{ datum: "2026-02-10", km: 40000, art: "Ölwechsel", betrieb: "Audi Zentrum" },
{ datum: "2025-03-05", km: 25000, art: "Inspektion", betrieb: "Audi Zentrum" },
],
},
technik: [
{ gruppe: "Motor", posten: [["Bauart", "V6 Biturbo"], ["Leistung", "331 kW"]] },
],
ausstattung: [{ gruppe: "RS-Paket", sonder: ["RS competition"], serie: ["Klimaautomatik"] }],
}
export const beispielFahrten: Fahrt[] = [
{
trip_id: "t1",
ts_start: "2026-08-10T08:00:00+02:00",
ts_end: "2026-08-10T08:45:00+02:00",
duration_s: 2700,
distance_km: 42.5,
km_quelle: "odometer",
odo_start: 48200,
odo_end: 48242.5,
art: "arbeitsweg",
source: "ha",
status: "vollständig",
},
{
trip_id: "t2",
ts_start: "2026-08-09T23:10:00+02:00",
ts_end: "2026-08-09T23:40:00+02:00",
duration_s: 1800,
distance_km: 18.2,
km_quelle: "odometer",
odo_start: 48180,
odo_end: 48198.2,
art: "privat",
source: "manual",
status: "offen",
},
]
export const beispielTankvorgaenge: Tankvorgang[] = [
{
tank_id: "f1",
ts: "2026-08-08T17:20:00+02:00",
liters: 48.7,
fuel_total_eur: 89.12,
price_per_l: 1.83,
station_name: "Shell München Ost",
station_address: "Musterstraße 5",
product_name: "V-Power",
odometer_km: 48100,
distance_km: 520,
receipt_file: "beleg-2026-08-08.pdf",
receipt_no: "4711",
discount: 3.4,
source: "beleg",
status: "vollständig",
},
]
/** Datenschicht-Ersatz für Tests: liefert Beispieldaten, schreibt ins Leere. */
export function beispielApi(): DataMetricApi {
const horcher = { verbindung: [] as ((z: string) => void)[] }
return {
rest: {
zustandLesen: async () => ({
entity_id: "pyscript.audi_dashboard_fahrzeugstatus",
state: "aktuell",
attributes: { daten: beispielStatus },
last_changed: "2026-08-11T09:00:00+00:00",
last_updated: "2026-08-11T09:00:00+00:00",
}),
datenLesen: async () => [],
dienstAufrufen: async () => null,
verbindungPruefen: async () => ({ message: "API running." }),
zugangSetzen: () => {},
},
live: {
aufVerbindung: (h: (z: string) => void) => {
horcher.verbindung.push(h)
h("verbunden")
return () => {}
},
aufZustand: () => () => {},
starten: async () => {},
beenden: () => {},
},
warteschlange: {
laden: async () => {},
aufAenderung: (h: (a: unknown[]) => void) => {
h([])
return () => {}
},
anzahl: 0,
},
profilLesen: async () => beispielProfil,
fahrtenLesen: async () => beispielFahrten,
tankvorgaengeLesen: async () => beispielTankvorgaenge,
fahrzeugstatusLesen: async () => beispielStatus,
profilSchreiben: async () => null,
belegHochladen: async () => null,
fahrtAnlegen: async () => null,
fahrtLoeschen: async () => null,
tankvorgangAnlegen: async () => null,
tankvorgangAktualisieren: async () => null,
tankvorgangLoeschen: async () => null,
jetztAktualisieren: async () => null,
} as unknown as DataMetricApi
}
+23
View File
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from "vitest"
import { themeStartwert } from "./theme"
describe("Theme", () => {
beforeEach(() => {
localStorage.clear()
})
it("startet ohne gespeicherte Wahl im Nachtmodus", () => {
expect(themeStartwert()).toBe("nacht")
})
it("übernimmt eine gespeicherte Wahl", () => {
localStorage.setItem("dm360.theme", "tag")
expect(themeStartwert()).toBe("tag")
})
it("ignoriert unsinnige gespeicherte Werte", () => {
localStorage.setItem("dm360.theme", "irgendwas")
expect(themeStartwert()).toBe("nacht")
})
})
+81
View File
@@ -0,0 +1,81 @@
/**
* Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits):
* gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht".
* Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher
* Browser werfen.
*/
import { useCallback, useEffect, useState } from "react"
export type Theme = "nacht" | "tag"
const SCHLUESSEL = "dm360.theme"
function gespeichertLesen(): Theme | null {
try {
const wert = localStorage.getItem(SCHLUESSEL)
return wert === "nacht" || wert === "tag" ? wert : null
} catch {
return null
}
}
function gespeichertSchreiben(theme: Theme): void {
try {
localStorage.setItem(SCHLUESSEL, theme)
} catch {
// Privatmodus — die Wahl gilt dann nur für diese Sitzung
}
}
export function themeStartwert(): Theme {
const gespeichert = gespeichertLesen()
if (gespeichert) return gespeichert
const hell =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: light)").matches
return hell ? "tag" : "nacht"
}
export function useTheme(): [Theme, (theme: Theme) => void] {
const [theme, setzeTheme] = useState<Theme>(themeStartwert)
const setzen = useCallback((neu: Theme) => {
setzeTheme(neu)
gespeichertSchreiben(neu)
}, [])
// Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
// Balken über einer dunklen App steht.
useEffect(() => {
const marke = document.querySelector('meta[name="theme-color"]')
if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff")
}, [theme])
return [theme, setzen]
}
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
const [an, setzeAn] = useState<boolean>(() => {
try {
return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus"
} catch {
return true
}
})
const setzen = useCallback((neu: boolean) => {
setzeAn(neu)
try {
localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
} catch {
// Privatmodus
}
}, [])
return [an, setzen]
}
+3 -1
View File
@@ -4,6 +4,8 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
@@ -16,5 +18,5 @@
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
// Basispfad relativ: die App wird sowohl unter einer eigenen Domain als auch
// aus einem Unterordner heraus ausgeliefert (Home Assistant: /local/dm360/),
// und in der Capacitor-Hülle direkt vom Dateisystem.
export default defineConfig({
base: "./",
plugins: [react()],
server: { port: 5173 },
build: {
outDir: "dist",
target: "es2022",
sourcemap: true,
},
})
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
setupFiles: ["./src/tests/aufbau.ts"],
},
})
+5 -42
View File
@@ -509,9 +509,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -613,9 +610,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -630,9 +624,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -647,9 +638,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -664,9 +652,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -681,9 +666,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -698,9 +680,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -715,9 +694,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -732,9 +708,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -749,9 +722,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -766,9 +736,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -783,9 +750,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -800,9 +764,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -817,9 +778,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -930,6 +888,7 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1066,6 +1025,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -1278,6 +1238,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1356,6 +1317,7 @@
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1613,6 +1575,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+4 -1
View File
@@ -29,7 +29,10 @@
--line-strong: rgba(255, 255, 255, .20);
--fg: #FFFFFF;
--fg2: #9aa1ad;
--fg3: #657081;
/* Design-Audit A2: von #657081 (nur 3,0:1 auf --tile, WCAG AA verfehlt) auf
4,9:1 angehoben. Das Panel hat diesen Wert bereits, hier war er noch der
alte — nachgezogen, damit beide dasselbe zeigen. */
--fg3: #8a94a3;
--ok: #15da15;
--warn: #ffaa00;
--bad: #fd2c4e;
+126
View File
@@ -0,0 +1,126 @@
# Reverse-Proxy: Pfad-Freigabeliste für DataMetric360
**Vorbereitung, noch nicht in Betrieb.** Gehört zu Phase 12 des
`../UMSETZUNGSPLAN.md`; die Entscheidung dahinter steht in
`../COMPANION_APP_ARCHITECTURE.md` §4.
Ziel: Die App erreicht Home Assistant von unterwegs, **ohne dass Home Assistant
selbst im Internet steht**. Dazu ein Cloudflare-Tunnel (baut nur nach außen auf,
kein Port am Router) auf einen Reverse Proxy, der ausschließlich die unten
aufgeführten Pfade durchlässt.
## Was die App wirklich braucht
Aus dem tatsächlichen Code der Datenschicht (`companion-app/src/api/`)
abgeleitet, nicht geschätzt:
| Methode | Pfad | Wofür |
|---|---|---|
| `GET` | `/api/` | Verbindungsprüfung bei der Ersteinrichtung |
| `GET` | `/api/states/pyscript.audi_dashboard_*` | Profil, Fahrten, Tankvorgänge, Status, Batterieverlauf, Belegergebnis, Updatestatus |
| `GET` | `/api/states/pyscript.reifen_*` | Reifensatz-Kilometerstände |
| `POST` | `/api/services/pyscript/audi_dashboard_*` | alle schreibenden Vorgänge |
| `GET` (Upgrade) | `/api/websocket` | Live-Aktualisierung |
Alles andere wird geblockt, insbesondere `/auth/*`, `/lovelace*`, `/config*`,
`/api/config`, `/api/history*`, `/developer-tools*` und die Oberfläche selbst.
## Ehrliche Einschränkung
**Der WebSocket lässt sich nicht pfadgenau beschneiden.** Nach `auth_ok` kann
über `/api/websocket` grundsätzlich jeder Zustand gelesen werden, nicht nur die
`pyscript.*`-Entitäten. Die Freigabeliste ist an dieser Stelle also grobkörniger
als beim REST-Zugriff.
Was bleibt: Ohne gültigen Token kommt gar keine Verbindung zustande, und ein
verlorenes Gerät wird durch Zurückziehen genau seines Tokens ausgesperrt. Das
ist die im Architekturdokument bewusst akzeptierte Abwägung — sie sollte nur
nicht in Vergessenheit geraten.
Wer sie nicht eingehen will, hat eine Alternative: den WebSocket weglassen und
die App auf regelmäßiges Abfragen umstellen. Kostet Akku und Datenvolumen,
verkleinert die Angriffsfläche aber auf die exakt aufgeführten REST-Pfade.
## Nginx Proxy Manager
Im Add-on unter *Hosts → Proxy Hosts → Edit → Advanced* eintragen. Ziel ist der
interne Name der Home-Assistant-Instanz (im Supervisor-Netz `homeassistant:8123`).
```nginx
# Reihenfolge zählt: die erlaubenden Blöcke stehen vor dem pauschalen Verbot.
location = /api/ {
proxy_pass http://homeassistant:8123;
include conf.d/include/proxy.conf;
}
location ~ ^/api/states/pyscript\.(audi_dashboard_[a-z_]+|reifen_[a-z_]+)$ {
limit_except GET { deny all; }
proxy_pass http://homeassistant:8123;
include conf.d/include/proxy.conf;
}
location ~ ^/api/services/pyscript/audi_dashboard_[a-z_]+$ {
limit_except POST { deny all; }
proxy_pass http://homeassistant:8123;
include conf.d/include/proxy.conf;
}
location = /api/websocket {
proxy_pass http://homeassistant:8123;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
include conf.d/include/proxy.conf;
}
# Die Fotos der Fahrzeuge, die die App anzeigt. Nur Lesen, nur Bilder.
location ~ ^/local/bilder/[a-z0-9_-]+\.(webp|png|jpg|svg)$ {
limit_except GET { deny all; }
proxy_pass http://homeassistant:8123;
include conf.d/include/proxy.conf;
}
# Alles Übrige: nicht durchlassen.
location / {
return 404;
}
```
Wird die App selbst unter derselben Adresse ausgeliefert, braucht sie einen
eigenen Block auf ihr Verzeichnis — sauberer ist ein getrennter Hostname
(`datametric360.app` für die App, `api.datametric360.app` für die
Schnittstelle), dann bleibt diese Liste unverändert.
## Nach der Einrichtung prüfen
Von einem Netz ohne VPN, etwa über Mobilfunk:
```bash
API=https://api.datametric360.app
T=<Zugriffstoken>
# muss gehen
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/"
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
"$API/api/states/pyscript.audi_dashboard_profil"
# muss 404 oder 403 liefern
curl -s -o /dev/null -w '%{http_code}\n' "$API/auth/authorize"
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/config"
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
"$API/api/states/sensor.audi_rs_4_avant_mileage"
# ohne Token: 401, nicht 200
curl -s -o /dev/null -w '%{http_code}\n' "$API/api/states/pyscript.audi_dashboard_profil"
```
Zusätzlich: Port 8123 darf von außen **gar nicht** antworten.
## Offen bis zur Einrichtung
- Nameserver von `datametric360.app` auf Cloudflare umstellen („Full setup")
- Entscheidung Nginx Proxy Manager gegen Traefik (die Liste oben ist für NPM
geschrieben und für Traefik sinngemäß zu übertragen)
- Endgültige Hostnamen festlegen und hier eintragen
+4
View File
@@ -53,6 +53,8 @@ def _backup_jetzt():
def _letztes_backup_vermerken():
p = profil.profil_lesen()
if p is None:
return
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
@@ -69,6 +71,8 @@ def audi_dashboard_backup_jetzt():
@time_trigger("cron(0 4 * * *)")
def _automatisches_backup():
p = profil.profil_lesen()
if p is None:
return
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
+1 -1
View File
@@ -38,7 +38,7 @@ import einstellungen
import frontend_veroeffentlichung
import profil
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # TODO: Datei ablegen
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
# --------------------------------------------------------------- Hilfsfunktionen
+2
View File
@@ -42,6 +42,8 @@ _fahrt_start_ts = None
def _pausenzeit_sekunden():
p = profil.profil_lesen()
if p is None:
return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60

Some files were not shown because too many files have changed in this diff Show More