Compare commits

...

10 Commits

Author SHA1 Message Date
Paul Nothaft 5e4ef2a327 Plan und AGENTS.md auf den erreichten Stand bringen
Elf von dreizehn Phasen sind erledigt oder vorbereitet. AGENTS.md haelt
zusaetzlich die drei Fehler fest, die erst der Betrieb gegen eine echte
Home-Assistant-Instanz zutage brachte - und den verworfenen selbstgebauten
QR-Erzeuger, damit niemand den Versuch wiederholt.
2026-08-11 11:28:34 +02:00
Paul Nothaft f8b877d555 Phase 10-13: PWA fertig, blockierte Phasen vorbereitet
PWA laeuft: Manifest, Symbole in allen noetigen Groessen und die
iOS-Angaben, die Apple statt des Manifests auswertet. Das Startsymbol ist
bewusst neutral - die Vier Ringe auf einem Homescreen waeren nach aussen
sichtbar und fielen nicht mehr unter die private Nutzung.

Vorbereitet, aber nicht ausgefuehrt: Capacitor-Konfiguration, ein
Ablage-Adapter fuer Schluesselbund und Keystore (greift defensiv auf die
Laufzeit zu, damit die App ohne das Plugin unveraendert weiterlaeuft), die
Pfad-Freigabeliste fuer den Reverse Proxy und das Geruest der
FMM003-Zuordnungstabelle.

Die QR-Seite fuer die Einrichtung war zunaechst ein Fehlgriff: Ich hatte den
QR-Erzeuger selbst geschrieben. Der Vergleich gegen eine erprobte
Implementierung zeigte 1239 abweichende Module von 3249 - der Code waere
unlesbar gewesen. Jetzt liegt eine bewaehrte Bibliothek (MIT, 57 KB) neben
der Seite im eigenen Home Assistant. Das erfuellt die eigentliche
Anforderung genauso: kein Netzzugriff, der Token verlaesst das eigene Netz
nicht. Geprueft ueber einen Umlauf - 233 Zeichen hinein, identisch wieder
heraus.
2026-08-11 11:27:20 +02:00
Paul Nothaft a68817065b Phase 9: Tests fuer die portierte Rechenlogik
41 weitere Tests fuer Statistik, Service-Prognose und Formatierung. Die
Faelle sind entlang der Regeln gewaehlt, die beim Portieren wichtig waren:
Wochenbeginn Montag, Nachtspanne ueber Mitternacht, mengengewichteter
Durchschnittspreis statt Mittel der Einzelpreise, Plausibilitaetsfenster beim
Langzeitverbrauch, Neustart der Oelprognose ab dem letzten Wechsel samt
Zeitlimit-Deckelung, BOM und Semikolon in der CSV-Ausgabe.

Ein Test war zunaechst falsch: er verglich das UTC-Datum gegen eine
Ortszeit-Erwartung. Der Code rechnet bewusst lokal - sonst begaenne der
Monat in unserer Zeitzone einen Tag zu frueh.

companion-app/README.md auf den erreichten Stand gebracht.

Gesamt: 90 Unit- und Rendertests plus 9 Pruefungen gegen die laufende
Home-Assistant-Instanz.
2026-08-11 11:21:56 +02:00
Paul Nothaft 571d12310f Phase 8: Audi-Schrift, Vier-Ringe und Typenschilder
Die drei Schriftschnitte stammen aus dem Panel, wo sie als base64 im
Stylesheet lagen - als eigene woff2-Dateien sind sie zwischenspeicherbar
statt bei jedem Laden erneut uebertragen zu werden. Typenschilder und Ringe
wechseln mit dem Erscheinungsbild.

Lizenzgrenze eingehalten und geprueft: design-system bleibt unveraendert und
enthaelt keine Markendateien, weil es nach aussen hochgeladen wird. Die App
darf sie nutzen, weil sie nur seitlich installiert wird.

Dabei noch eine Typungenauigkeit korrigiert: technik und ausstattung im
Profil sind Listen von Gruppen, waren aber wie die uebrigen Abschnitte als
Objekt deklariert.
2026-08-11 11:19:38 +02:00
Paul Nothaft 8e6e5bc2ee Phase 7c: restliche Screens - alle 21 Seiten stehen
Fahrzeugdaten, Batterieverlauf, Service mit Werkstatt und Servicebuch,
Reifen, die ganze Versicherungs- und Steuergruppe, Einstellungen in voller
Tiefe und die Live-Ansicht.

Portiert dazu: die eigene Oelwechsel-Prognose (rechnet ab dem letzten
Wechsel im Servicebuch neu, weil der Langzeitschnitt dafuer zu traege ist),
Kalenderdateien fuer Termine und die CSV-Ausgabe im deutschen Format.

Die Live-Ansicht ist gebaut, aber ueber einen Schalter abgeschaltet: die
Werte dafuer liefert erst der FMM003. Ein Menuepunkt mit lauter Nullen waere
schlechter als keiner.

Zwei Stellen, an denen die App bewusst zurueckhaltend ist: ein unbekannter
Pruefpunkt erscheint als hohler Ring statt als geratenes Gruen, und der
Reifen-Kilometerstand wird nie zurueckgeschrieben, weil ihn der Server
fortschreibt.

49 Tests, darunter ein Rendertest ueber alle 21 Seiten in beiden Layouts
mit Beispieldaten in der Form, die das Backend wirklich liefert.
2026-08-11 11:17:47 +02:00
Paul Nothaft 8b64a808ed Phase 7b: Fahrten, Tanken und Statistik
Listen mit Jahr-Monat-Aufklappung, Detailseiten, manuelles Eintragen und
Beleg-Upload. Statistik rechnet ueber die portierten Regeln.

Zwei offene Audit-Befunde des alten Panels sind hier gleich mit erledigt:

1. Loeschen war ausschliesslich per Wischgeste erreichbar. Wer mit
   Sprachausgabe oder Schaltersteuerung bedient, wischt nicht - Fahrten und
   Tankvorgaenge waren fuer diese Nutzer gar nicht loeschbar. Jede Zeile hat
   jetzt zusaetzlich ein sichtbares Menue, ein richtiger Knopf mit Tastatur.
2. Leaflet kam per CDN und blieb auf einem Handy ohne eigenen Internetzugang
   still leer. Es liegt jetzt im Buendel (eigener Abschnitt, erst bei Bedarf
   geladen).

Bewusst NICHT uebernommen: fakeTrack() aus dem alten Panel, das eine
erfundene Linie zwischen zwei Punkten zeichnete, wenn keine Route vorlag.
Solange das Fahrzeug keine Positionen liefert, sagt die Seite das - statt
eine glaubwuerdig aussehende Erfindung zu zeigen.
2026-08-11 11:11:00 +02:00
Paul Nothaft 4dbf3cb173 Phase 7a: Uebersicht, Fahrzeugstatus und Mein Audi
Erste drei Screens auf der fertigen Datenschicht. Uebersicht mit Fahrzeugbild,
Sicherungsstatus, Reichweite und Tankfuellstand, Kilometerstand samt
Service-Faelligkeit und Vorschau auf letzte Fahrt und Tankung. Fahrzeugstatus
zeigt die 16 Einzelpruefungen, wobei ein unbekannter Sensor als hohler Ring
erscheint statt als gruen geratener Punkt - dieselbe Vorsicht, die schon das
Backend walten laesst. Mein Audi buendelt Fotogalerie und Wege zu den
Unterseiten.

Dazu die geteilte Rechenlogik der Statistik portiert (Zeitraeume,
Langzeitverbrauch mit Plausibilitaetsfenster, Gruppierung nach Jahr und
Monat) und Formatierung im deutschen Format.

Bild-Komponente kapselt eine Reibung zwischen App und Bibliothek: die App
laeuft mit exactOptionalPropertyTypes, ein undefined an einem optionalen Prop
waere sonst an jeder Aufrufstelle einzeln zu behandeln.
2026-08-11 11:07:03 +02:00
Paul Nothaft 0103508cea Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige
Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die
vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht
Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder
REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie
speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token
und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene
Reaktionen verlangen.

Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage
brachte:

1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt
   und sicherheit. Das Backend schreibt tankprozent, gesichert und
   sicherheitscheck - die Oberflaeche haette still ueberall undefined
   gelesen, ohne dass irgendetwas fehlgeschlagen waere.
2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein
   Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen,
   den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu
   ueberschreiben. Abschnitte werden jetzt kopiert.

Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil
Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests
laufen.

Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf,
vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests.
2026-08-11 11:02:44 +02:00
Paul Nothaft 5c0c2d1898 Phase 5: App-Shell mit responsivem Layout, Navigation und Theme
Zwei Layouts aus derselben Zusammensetzung: unter 1000px Tab-Leiste unten,
darueber Seitenleiste links mit Brotkrume statt Zurueck-Pfeil. Navigation
ist wie im alten Panel ein flacher Zustand mit fester ZURUECK-Tabelle statt
eines Router-Pakets - die Verschachtelung ist hoechstens dreistufig.

Theme und Tab-Beschriftung ueberleben einen Neustart (localStorage,
abgesichert fuer den Privatmodus), Startwert folgt der Systemeinstellung.
Symbole aus dem alten Panel uebernommen, damit App und Panel gleich aussehen.

Neun Rendertests belegen die Abnahmekriterien: Layoutwechsel in beide
Richtungen, Zurueck-Ziel ueber die Tabelle, Zahnrad statt Tab-Leiste fuer
die Einstellungen, Theme-Persistenz.
2026-08-11 10:55:10 +02:00
Paul Nothaft 2fe3576682 Phase 4: Vite-React-Geruest und Workspace-Anbindung
npm-Workspace im Repo-Root verbindet companion-app mit @audi-dash/ui, so
dass die App die Komponentenbibliothek als echte Abhaengigkeit nutzt statt
nur in der Doku. Dazu Vite, React 18, Einstiegsdateien und eine schlanke
Grundlage-CSS, die nur enthaelt was eine App braucht und eine Bibliothek
nicht mitbringt: Vollbild, sichere Bereiche, Scrollverhalten.

Zwei Audit-Befunde des alten Panels sind hier von Anfang an beruecksichtigt:
Formularfelder mit 16px (sonst zoomt iOS Safari beim Fokus) und ein
globaler :focus-visible-Ring.

Nebenbei im design-system den Kontrastwert --fg3 nachgezogen: dort stand
noch #657081 mit 3,0:1 auf --tile, das Panel hatte den Wert laengst auf
#8a94a3 korrigiert. Ohne das haette die neue App den bereits behobenen
WCAG-Verstoss geerbt.
2026-08-11 10:50:51 +02:00
100 changed files with 15383 additions and 118 deletions
+57 -31
View File
@@ -1,6 +1,6 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-11** (full-repo review). This file is the entry point for every new agent
**Last updated: 2026-08-11** (after building the app — phases 1, 2, 410 of `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.
@@ -77,10 +77,11 @@ assets. Never mix these the other way around.
| 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 (PWA today, Capacitor iOS/Android later, HA iframe); will **replace** the panel | **all 21 screens built and tested**; PWA works; Capacitor hull pending |
| `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
@@ -96,12 +97,12 @@ km counter, backup, self-update, image management. Frontend: one file `www/audi-
`pyscript/modules/einstellungen.py` (the one file edited before install). 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
@@ -138,7 +139,7 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
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**;
@@ -150,19 +151,21 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
`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).
@@ -172,6 +175,29 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac
---
### 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.
**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.
## Open items
Execution order, exact steps, and acceptance criteria for every item below live in
@@ -181,20 +207,20 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
### 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)
@@ -203,7 +229,7 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
- [ ] 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)
- [ ] Generate TLS certificates for Mosquitto + device (small private CA); FMM003 refuses MQTT
without them
@@ -216,10 +242,10 @@ wraps the web app for iPhone; a PWA home-screen install is the accepted intermed
### C) Maintain the existing HA panel (low priority — being replaced)
- [ ] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete
- [x] Fix documentation drift (statistics claim, INSTALL variable names, README gaps, obsolete
TODO comment) — text-only changes
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
- [ ] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
- [x] Decide whether the 3 audit leftovers get fixed here or 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)
+34 -11
View File
@@ -91,7 +91,10 @@ einzige, die zwingend auf Hardware wartet.
---
## Phase 2 — Pflege des bestehenden HA-Panels (Quick Wins, Block C aus AGENTS.md)
## 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.
@@ -132,7 +135,12 @@ Besitzer in der App/UI, kein Code).
---
## Phase 3 — Design-Entwurf vervollständigen (Claude Design)
## 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
@@ -176,7 +184,7 @@ Hauptscreens sind entworfen; die 16 Unterseiten folgen dann dem gleichen Muster.
---
## Phase 4 — Workspace und React-Gerüst in `companion-app/`
## 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.
@@ -239,7 +247,7 @@ echte Dependency nutzt. Die bestehende Datenschicht (`src/api/`) bleibt unverän
---
## Phase 5 — App-Shell: Theme, Router, responsives Layout
## 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.
@@ -273,7 +281,7 @@ Seitenleiste), mit Tag/Nacht-Theme und Zurück-Navigation. Noch ohne echte Daten
---
## Phase 6 — Onboarding, Datenanbindung, Offline-UX
## Phase 6 — Onboarding, Datenanbindung, Offline-UX ✅ ERLEDIGT (2026-08-11)
**Ziel:** Die App verbindet sich echt mit Home Assistant über die vorhandene Datenschicht.
@@ -313,7 +321,12 @@ Zugangsdaten-Verwaltung (`umgebung.ts`).
---
## Phase 7 — Alle Screens umsetzen
## 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
@@ -355,7 +368,7 @@ nicht ein Riesencommit.
---
## Phase 8 — Audi-Assets einbauen
## Phase 8 — Audi-Assets einbauen ✅ ERLEDIGT (2026-08-11)
**Ziel:** Die App sieht aus wie das Original — Schriften, Ringe, Typenschilder.
@@ -376,7 +389,9 @@ nicht ein Riesencommit.
---
## Phase 9 — Tests
## 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.
@@ -401,7 +416,10 @@ nicht ein Riesencommit.
---
## Phase 10 — PWA und Capacitor (iPhone/Android, nur Sideload)
## Phase 10 — PWA und Capacitor 🟡 STUFE 1 ERLEDIGT (2026-08-11)
> PWA fertig. Capacitor-Konfiguration, Secure-Storage-Adapter und die QR-Seite
> liegen bereit; die Hülle selbst braucht einen Mac mit Xcode und ein Gerät.
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
(Keychain + QR-Scan).
@@ -461,7 +479,10 @@ nicht ein Riesencommit.
---
## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy
## 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).
@@ -496,7 +517,9 @@ Schritte 24 können gegen die bestehende HA-API **vor** der FMM003-Hardware e
---
## Phase 13 — FMM003-Inbetriebnahme (wartet auf Hardware)
## Phase 13 — FMM003-Inbetriebnahme 🟡 VORBEREITET (wartet auf Hardware)
> Gerüst der Zuordnungstabelle und alle Schritte: `homeassistant/FMM003_MAPPING.md`.
**Ziel:** 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.**
+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
+51
View File
@@ -0,0 +1,51 @@
/**
* Capacitor-Konfiguration für die native Hülle.
*
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
*
* npm i -D @capacitor/cli
* npm i @capacitor/core @capacitor/ios @capacitor/android
* npx cap add ios && npx cap add android
* npm run build && npx cap sync
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
*
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
* ausschließlich für diese private Installation freigegeben.
*
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
*/
interface CapacitorKonfiguration {
appId: string
appName: string
webDir: string
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
ios?: { contentInset?: string; backgroundColor?: string }
android?: { backgroundColor?: string }
}
const konfiguration: CapacitorKonfiguration = {
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",
},
}
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>
+22 -3
View File
@@ -2,14 +2,33 @@
"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": {
"@audi-dash/ui": "*",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@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>
)
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 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)
return (
<ImagePlaceholder
{...(url ? { src: url } : {})}
{...(groesse ? { size: groesse } : {})}
alt={beschreibung}
label={name}
hint={datei ?? undefined}
/>
)
}
+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 (
<>
<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>
<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>
</>
)
}
+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>
)
}
+52
View File
@@ -0,0 +1,52 @@
/**
* 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",
})
/** Modellname aus dem Profil → Dateikürzel des Typenschilds. */
export function schildKuerzel(titel: string): string | null {
const norm = titel.toLowerCase().replace(/\s+/g, " ").trim()
const treffer = /\b(rs)\s?([34567])\b/.exec(norm) ?? /\b(s)\s?(q7|3)\b/.exec(norm)
if (!treffer) return null
const [, reihe, zahl] = treffer
return `${reihe}${zahl}`.replace("sq7", "sq7")
}
export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
const [theme] = useTheme()
const kuerzel = schildKuerzel(modell)
if (!kuerzel) return null
const variante = theme === "nacht" ? "negative" : "positive"
const pfad = `../assets/audi/typenschilder/${kuerzel}-${variante}.svg`
const quelle = SCHILDER[pfad]
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" }}
/>
)
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Ü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 } 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
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={20} />
<div className="ads-title">
{einstellungen.fahrzeugtitel}{" "}
{einstellungen.fahrzeugzusatz && (
<span className="ads-sport">{einstellungen.fahrzeugzusatz}</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;
}
}
+200
View File
@@ -0,0 +1,200 @@
/*
* 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);
}
.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;
}
+570
View File
@@ -0,0 +1,570 @@
/*
* 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);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -------------------------------------------------------- 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);
}
+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"],
},
})
+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;
+136
View File
@@ -0,0 +1,136 @@
# FMM003 → Home Assistant: Zuordnungstabelle
**Gerüst, noch nicht ausgefüllt.** Gehört zu Phase 13 des
`../UMSETZUNGSPLAN.md`; die Architektur dahinter steht in
`../COMPANION_APP_ARCHITECTURE.md` §2b.
> **Grundregel: nicht raten.** Wie die CAN/FMS-Werte im Codec JSON heißen, ist
> unbekannt und je nach Firmware verschieden. Die Tabelle unten wird aus einer
> **echten mitgeschnittenen Nachricht** gefüllt, nicht aus der Dokumentation
> abgeleitet. Bis dahin bleiben die Felder leer.
## Datenweg
```
FMM003 (CAN/FMS) ──MQTT über TLS──▶ Mosquitto-Add-on ──▶ MQTT-Integration ──▶ Entitäten
```
Traccar entfällt: Das Gerät liefert bereits JSON, es gibt nichts zu dekodieren.
## Schritt 1 — Gerät einstellen
Im Teltonika Configurator:
| Ort | Einstellung |
|---|---|
| System → System Settings → Data Protocol | **Codec JSON** |
| GPRS → Server Settings | Broker-IP und Port **8883** |
| GPRS → Server Settings | Benutzername und Passwort des Brokers |
| Security | Wurzelzertifikat, Gerätezertifikat, privater Schlüssel |
**Firmware-Version hier eintragen:** `________________`
Codec JSON ist nicht in jeder Firmware enthalten (bei mehreren Modellen erst ab
03.28.00). Ohne hochgeladene Zertifikate verweigert das Gerät die
MQTT-Verbindung — das ist kein Fehler, sondern Absicht.
## Schritt 2 — Nachricht mitschneiden
```bash
mosquitto_sub -h <broker> -p 8883 --cafile ca.crt \
-u <benutzer> -P <passwort> -t '#' -v | tee mitschnitt.txt
```
Dabei nacheinander: Zündung an, kurz fahren, anhalten, Zündung aus. Danach den
Mitschnitt sichten und die Tabelle unten füllen.
**Nicht ins Repository legen** — der Mitschnitt enthält echte Positionsdaten.
## Schritt 3 — Zuordnungstabelle
Aus dem Mitschnitt zu füllen. Die Spalte „Entität" nennt den Namen, unter dem
das Backend den Wert erwartet.
| JSON-Feld | Bedeutung | Einheit | Entität in HA | Gefunden am |
|---|---|---|---|---|
| | Zündung an/aus | 0/1 | `binary_sensor.audi_zuendung` | |
| | Geschwindigkeit | km/h | `sensor.audi_geschwindigkeit` | |
| | Position Breite | Grad | `device_tracker.audi_position` | |
| | Position Länge | Grad | `device_tracker.audi_position` | |
| | Kilometerstand | km | `sensor.audi_kilometerstand` | |
| | Tankfüllstand | % | `sensor.audi_tankfuellstand` | |
| | Drehzahl | min⁻¹ | `sensor.audi_drehzahl` | |
| | Kühlmitteltemperatur | °C | `sensor.audi_kuehlmittel` | |
| | Bordspannung | V | `sensor.audi_bordspannung` | |
| | Zeitstempel | ISO/Epoch | — (Attribut) | |
Teltonika nennt CAN-Werte oft nur über eine AVL-Kennung (etwa `"12"` für
Kraftstoffstand). Deshalb hier immer **beides** notieren: die Kennung aus der
Nachricht und ihre Bedeutung aus der AVL-Liste des Geräts.
## Schritt 4 — MQTT-Integration
Aus der ausgefüllten Tabelle entstehen die Entitäten. Gerüst für
`configuration.yaml`, die Feldnamen in den Vorlagen sind erst danach einsetzbar:
```yaml
mqtt:
binary_sensor:
- name: "Audi Zündung"
state_topic: "teltonika/<imei>/data"
value_template: "{{ 'ON' if value_json.<FELD> | int == 1 else 'OFF' }}"
device_class: power
sensor:
- name: "Audi Geschwindigkeit"
state_topic: "teltonika/<imei>/data"
value_template: "{{ value_json.<FELD> }}"
unit_of_measurement: "km/h"
state_class: measurement
device_tracker:
- name: "Audi Position"
state_topic: "teltonika/<imei>/data"
json_attributes_topic: "teltonika/<imei>/data"
# latitude/longitude erwartet HA als Attribute
```
## Schritt 5 — Fahrterkennung umstellen
Neues `pyscript/fahrterkennung_fmm003.py` nach dem Muster von
`pyscript/fahrterkennung.py`, mit zwei Unterschieden:
1. Der Auslöser ist die **Zündungs-Entität**, nicht der WLAN-Sensor.
2. Der Startzeitpunkt darf nicht mehr nur im Arbeitsspeicher liegen — beim
FMM003 gibt es keinen Grund mehr für diese Einschränkung, weil der Zustand
jederzeit vom Gerät neu gelesen werden kann.
**Unverändert übernehmen:** die Pausentoleranz (`fahrten_pausenzeit_min` aus dem
Profil, Standard 15 Minuten) über `task.unique()` und `task.sleep()`. Das ist
eine bewusste Produktentscheidung, dreimal durchdacht: Springt der Motor
innerhalb der Toleranz wieder an, ist es **eine** Fahrt, nicht zwei. Die Zündung
fällt bei Start-Stopp nicht ab, ein zusätzlicher Filter dafür ist also
unnötig.
Vorgehen: erst parallel laufen lassen und in eine Testdatei schreiben, mit der
bisherigen Erkennung vergleichen, dann scharf schalten. Danach
`WLAN_SENSOR` aus `pyscript/modules/einstellungen.py` entfernen.
## Schritt 6 — Live-Ansicht einschalten
In `companion-app/src/funktionen.ts` den Schalter `LIVE_VERFUEGBAR` auf `true`
setzen und in `companion-app/src/screens/LiveFahrt.tsx` die Feldnamen in
`liveZustandLesen()` an die tatsächlichen Entitäten anpassen.
Damit werden auch die Fahrtdetails erstmals echte Routen zeigen, statt nur
Start- und Zielpunkt.
## Noch zu entscheiden
- **Erreichbarkeit des Brokers für das Fahrzeug:** Portfreigabe 8883 direkt auf
Mosquitto gegen VPS-Broker mit Mosquitto-Bridge über Tailscale. Ersteres ist
einfacher, aber eine echte Freigabe; letzteres öffnet zuhause nichts, kostet
aber einen zusätzlichen Server.
- **Datenvolumen:** Codec JSON braucht deutlich mehr Bytes als das binäre
Codec8E. Bei einem privat genutzten Fahrzeug vernachlässigbar, bei knappem
Mobilfunktarif im Blick behalten.
+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
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DataMetric360 — Gerät verbinden</title>
<!--
Erzeugt den Einrichtungs-QR-Code für die App, vollständig auf dem Gerät.
Der Punkt dabei: Ein QR-Generator im Internet bekäme den Zugangstoken zu
sehen. Diese Seite lädt ihre Bibliothek aus dem eigenen Home Assistant
(dm360-qrcode-lib.js daneben) und stellt keine einzige Netzanfrage — der
Token verlässt das eigene Netz nicht.
Ablegen unter /config/www/, aufrufbar als /local/dm360-qr.html.
Gegenstück in der App: UMSETZUNGSPLAN.md Phase 10 Stufe 2.
-->
<script src="./dm360-qrcode-lib.js"></script>
<style>
:root { color-scheme: dark; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #161b23; color: #fff;
font: 400 15px/1.55 Helvetica, Arial, sans-serif;
}
main { width: min(420px, 92vw); padding: 24px; }
h1 { font-size: 22px; font-weight: 300; margin: 0 0 6px; }
p { color: #9aa1ad; font-size: 13.5px; margin: 0 0 18px; text-wrap: pretty; }
label { display: block; font-size: 10px; letter-spacing: .2em;
text-transform: uppercase; color: #8a94a3; margin: 14px 0 6px; }
input {
width: 100%; box-sizing: border-box; padding: 11px 12px; font-size: 16px;
background: #1f2733; color: #fff; border: 1px solid rgba(255,255,255,.1);
border-radius: 8px; outline: none;
}
input:focus-visible { outline: 2px solid #F50537; outline-offset: 2px; }
button {
margin-top: 18px; width: 100%; padding: 13px; font-size: 15px;
background: #fff; color: #000; border: 0; border-radius: 999px; cursor: pointer;
}
#ausgabe { margin-top: 22px; display: none; text-align: center; }
#ausgabe.sichtbar { display: block; }
#ausgabe img { width: 100%; max-width: 320px; height: auto;
image-rendering: pixelated; background: #fff;
padding: 12px; border-radius: 12px; box-sizing: border-box; }
.warnung { color: #ffaa00; font-size: 12px; margin-top: 14px; }
.fehler { color: #fd2c4e; font-size: 13px; margin-top: 14px; }
</style>
</head>
<body>
<main>
<h1>Gerät verbinden</h1>
<p>
Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein.
Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet.
</p>
<label for="adresse">Server-Adresse</label>
<input id="adresse" type="url" placeholder="https://api.datametric360.app" />
<label for="token">Zugriffstoken</label>
<input id="token" type="password" placeholder="in Home Assistant unter Profil → Sicherheit" />
<button id="erzeugen" type="button">Code anzeigen</button>
<div id="ausgabe"></div>
<p id="meldung" class="fehler" hidden></p>
</main>
<script>
const ausgabe = document.getElementById("ausgabe")
const meldung = document.getElementById("meldung")
function melde(text) {
meldung.textContent = text
meldung.hidden = !text
}
document.getElementById("erzeugen").addEventListener("click", () => {
melde("")
ausgabe.classList.remove("sichtbar")
const url = document.getElementById("adresse").value.trim().replace(/\/+$/, "")
const token = document.getElementById("token").value.trim()
if (!url || !token) {
melde("Bitte Adresse und Token eintragen.")
return
}
const inhalt = JSON.stringify({ url, token })
try {
// Version 0 heißt: kleinstmögliche Version selbst wählen.
// Fehlerkorrektur L, weil der Code direkt vom Bildschirm abgelesen
// wird — es gibt keinen zerkratzten Aufkleber zu retten.
const qr = qrcode(0, "L")
qr.addData(inhalt)
qr.make()
ausgabe.innerHTML = qr.createImgTag(6, 0, "Einrichtungs-Code")
ausgabe.insertAdjacentHTML(
"beforeend",
'<p class="warnung">Wer diesen Code abfotografiert, hat vollen Zugriff auf die ' +
"Schnittstelle. Nach dem Einrichten die Seite schließen.</p>",
)
ausgabe.classList.add("sichtbar")
} catch (fehler) {
melde(
"Der Code konnte nicht erzeugt werden: " +
String((fehler && fehler.message) || fehler) +
". Ist der Token vollständig eingefügt?",
)
}
})
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+4922
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "audi-app",
"version": "0.1.0",
"private": true,
"description": "Werkstatt für DataMetric360: Design-System, Companion-App und das Home-Assistant-Panel.",
"workspaces": [
"design-system",
"companion-app"
],
"scripts": {
"build:ds": "npm run build --workspace @audi-dash/ui",
"dev": "npm run dev --workspace datametric360",
"build": "npm run build:ds && npm run build --workspace datametric360",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspace datametric360"
}
}