Merge branch 'umsetzung-datametric360': Companion-App-Phasen 1-10 uebernehmen
Bringt die vollstaendig gebaute DataMetric360-App zusammen (React/Vite, 21 Screens, Audi-Assets, PWA + native Capacitor-Huelle, 90+9 Tests) sowie die profil_lesen()-Haertung gegen fehlende/kaputte Profildatei zusammen. Konfliktaufloesung: - AGENTS.md, INSTALL.md, README.md: main-Fassung war jeweils die chronologisch neuere, uebernommen und um die durch den Merge tatsaechlich erledigten Punkte ergaenzt (profil_lesen()-Haertung, Audit-Reste-Entscheidung). - fahrterkennung.py: toten WLAN-Zweig vom Branch verworfen, Zuendungs- basierte Erkennung von main behalten. - homeassistant/FMM003_MAPPING.md (MQTT/Mosquitto-Ansatz vom 2026-08-11, vor der Umstellung auf flespi) bewusst nicht uebernommen - main nutzt seit 2026-08-12 flespi als alleinigen FMM003-Datenweg. UMSETZUNGSPLAN.md Phase 13 entsprechend als ueberholt markiert, verweist auf AGENTS.md als massgeblich. - REVIEW_main_2026-08-13.md, ha_install.md (add/add): main-Fassung war die spaetere Revision derselben Dokumente, uebernommen. Die von REVIEW_main_2026-08-13.md befuerchtete Merge-Falle (profil_lesen() gibt jetzt None zurueck, main-seitige Aufrufer pruefen das nicht) wurde verifiziert als bereits entschaerft: alle 5 Aufrufstellen im gemergten Stand (backup.py x2, fahrterkennung.py, frontend_veroeffentlichung.py, reifenzaehler.py, tankerkennung.py) sind None-sicher. installationspaket/ nicht Teil dieses Commits (gitignored, wird bei Bedarf neu zusammengestellt).
@@ -1,6 +1,7 @@
|
||||
# AGENTS.md — Project state, review findings, open items, and working rules
|
||||
|
||||
**Last updated: 2026-08-13** (design/CI review added, see `DESIGN_REVIEW_2026-08-13.md`). This file is the entry point for every new agent
|
||||
**Last updated: 2026-08-13** (merged the `umsetzung-datametric360` branch — companion app phases
|
||||
1–10 done, see `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
|
||||
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
|
||||
the linked documents — this file points, it does not duplicate.
|
||||
|
||||
@@ -73,19 +74,22 @@ assets. Never mix these the other way around.
|
||||
|
||||
**Reading order for a new session:**
|
||||
1. This file (overview + open items)
|
||||
2. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
|
||||
3. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, barely built)
|
||||
4. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
|
||||
5. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7)
|
||||
2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with
|
||||
commands and acceptance criteria); when working on an open item, follow the plan's phase
|
||||
3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
|
||||
4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 1–10 built)
|
||||
5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
|
||||
6. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7)
|
||||
|
||||
## The three projects in this repo
|
||||
|
||||
| Area | What | Status |
|
||||
|---|---|---|
|
||||
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** |
|
||||
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app |
|
||||
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
|
||||
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
|
||||
| `companion-app/` | **DataMetric360** — successor app (Capacitor iOS/Android + HA iframe); will **replace** the panel | 🚧 data layer only (~930 lines TS), **no UI** |
|
||||
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim (wrong model, main screens only) |
|
||||
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
|
||||
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
|
||||
|
||||
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
|
||||
`bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the
|
||||
@@ -103,12 +107,12 @@ Fahrzeug einrichten → Setup), stored as overrides in `data/entitaeten.json`;
|
||||
`pyscript/modules/einstellungen.py` holds only the built-in defaults. Deploy: `update.ps1`
|
||||
(robocopy to Samba share) or — still inactive — self-update from git.
|
||||
|
||||
**`companion-app/` — what exists:** dependency-free, strictly typed TS data layer (`src/api/`):
|
||||
REST client (`rest.ts`), WebSocket client with auth flow + reconnect backoff (`live.ts`),
|
||||
persistent offline write queue (`warteschlange.ts`, strict FIFO), environment detection
|
||||
capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), facade
|
||||
`DataMetricApi` (`index.ts`). Smoke test ran 7/7 green against Docker HA `audi_ha_test`
|
||||
(localhost:18123) — unauthenticated only; token-authenticated reads/writes untested.
|
||||
**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
|
||||
reconnect backoff, persistent offline write queue, credential storage), domain logic
|
||||
(`src/daten/`: profile adapter, statistics, service forecast, data context), all 21 screens
|
||||
(`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and icons. Verified by 90 unit
|
||||
and render tests plus 9 checks against a live Home Assistant. `npm run dev` in the repo root
|
||||
starts it; `testumgebung/aufsetzen.sh` provides the server side.
|
||||
|
||||
**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
|
||||
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
|
||||
@@ -170,7 +174,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||||
frontend: no tests, no CI.
|
||||
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.
|
||||
|
||||
### Documentation drift (small fixes; align docs to code)
|
||||
### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong)
|
||||
|
||||
- `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment
|
||||
`audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**;
|
||||
@@ -179,19 +183,21 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||||
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
|
||||
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).
|
||||
|
||||
### Audit leftovers (`AUDIT_2026-08-10.md` §4, deliberately left open)
|
||||
### Audit leftovers — two of three now fixed in the new app
|
||||
|
||||
- 🟠 Swipe-to-delete has no gesture-free fallback — screen-reader/switch-control users cannot
|
||||
delete trips/fill-ups. Needs a design decision (long-press vs. "…" button vs. action sheet).
|
||||
- 🟡 Popup close-by-tap-outside is not keyboard-reachable (needs a quick manual check).
|
||||
- 🟡 Self-host Leaflet JS/CSS (tiles necessarily stay remote).
|
||||
- ✅ Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also
|
||||
carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in
|
||||
the old panel, which is being replaced anyway.
|
||||
- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk.
|
||||
- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's
|
||||
Popup, which is keyboard-reachable).
|
||||
- Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel
|
||||
gets replaced anyway.
|
||||
|
||||
### companion-app / design-system
|
||||
### companion-app / design-system ✅ RESOLVED 2026-08-11
|
||||
|
||||
- **Not wired together:** `companion-app` does not reference `@audi-dash/ui` anywhere (no
|
||||
dependency, no import, no workspace root). The link exists only in prose.
|
||||
- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the
|
||||
app as a real dependency.
|
||||
- Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first
|
||||
(design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`).
|
||||
- No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system).
|
||||
@@ -201,24 +207,100 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||||
|
||||
---
|
||||
|
||||
### Found while building (2026-08-11) — all fixed
|
||||
|
||||
Three defects that only surfaced by running against a real Home Assistant, not by reading code:
|
||||
|
||||
1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had
|
||||
`tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes
|
||||
`tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without
|
||||
anything failing. `technik`/`ausstattung` were typed as objects but are arrays.
|
||||
2. **The profile adapter handed out live references into the raw profile.** Editing a form would
|
||||
have silently mutated the baseline and broken the promise never to overwrite the
|
||||
backend-maintained tire odometer. Sections are copied now.
|
||||
3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG
|
||||
AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a
|
||||
already-solved contrast defect.
|
||||
|
||||
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
|
||||
what the smoke scripts run on — rewritten as plain fields.
|
||||
|
||||
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
|
||||
|
||||
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
|
||||
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
|
||||
the block to zero height and took model name, badge and plate with it. Fixed with
|
||||
`.dm-inhalt > * { flex: none }`.
|
||||
5. **The model name appeared twice** — once as the badge image, once as text beside it.
|
||||
6. **Filenames overflowed the gallery thumbnails.**
|
||||
|
||||
And one in the backend, found by watching the log against a current Home Assistant: the odometer
|
||||
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
|
||||
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
|
||||
|
||||
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
|
||||
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
|
||||
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
|
||||
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
|
||||
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
|
||||
|
||||
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
|
||||
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
|
||||
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
|
||||
(no network call, token never leaves the local network) and round-trips correctly.
|
||||
|
||||
## Pending: this branch has diverged from `main` (noted 2026-08-13)
|
||||
|
||||
`ha_install.md` (root) plans the move from pyscript to a **native HA integration** with a config
|
||||
flow — UI-only setup, no YAML, and updates via a self-reporting `UpdateEntity` against the Gitea
|
||||
repo (HACS is GitHub-only, so it is not an option). All APIs in it were verified against the
|
||||
running 2026.8.1 instance. It also records which review findings that move eliminates by design.
|
||||
|
||||
A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
|
||||
three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
|
||||
mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
|
||||
which makes "securely parked" report safe while three doors were never checked).
|
||||
|
||||
`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
|
||||
menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
|
||||
owner keeps working on `main` first.
|
||||
|
||||
Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
|
||||
`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.
|
||||
|
||||
**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
|
||||
`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
|
||||
corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
|
||||
it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
|
||||
`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
|
||||
would raise `AttributeError` on a missing profile instead of logging a clear error.
|
||||
|
||||
When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
|
||||
every remaining `profil.profil_lesen()` call site.
|
||||
|
||||
## Open items
|
||||
|
||||
Execution order, exact steps, and acceptance criteria for every item below live in
|
||||
`UMSETZUNGSPLAN.md` (phases 1–13). Additional decisions of 2026-08-11: **no Electron** (Capacitor
|
||||
wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and
|
||||
**no separate backend** (the app talks to the HA REST/WebSocket API directly).
|
||||
|
||||
### A) Build DataMetric360 (the big block)
|
||||
|
||||
- [ ] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
|
||||
- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
|
||||
then re-export to `design/`
|
||||
- [ ] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
|
||||
- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
|
||||
dependency (possibly add a workspace/monorepo root)
|
||||
- [ ] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
|
||||
- [ ] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` —
|
||||
- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
|
||||
- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` —
|
||||
**never** into `design-system/`
|
||||
- [ ] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
|
||||
- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
|
||||
`ablageSetzen()` hook already exists)
|
||||
- [ ] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
|
||||
- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
|
||||
locally under HA `/local/`, architecture §3)
|
||||
- [ ] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
|
||||
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
|
||||
against `audi_ha_test` with a real token
|
||||
- [ ] Offline UX per design brief (offline marker, visible pending queue)
|
||||
- [x] Offline UX per design brief (offline marker, visible pending queue)
|
||||
|
||||
### B) Infrastructure / commissioning (partly waits for FMM003 hardware)
|
||||
|
||||
@@ -227,7 +309,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||||
- [ ] Decide hostname split (app on apex + API on `api.` subdomain, or vice versa)
|
||||
- [ ] Choose reverse proxy (Nginx Proxy Manager vs. Traefik) — **can be done before hardware**
|
||||
against the existing HA API
|
||||
- [ ] Define the reverse-proxy path allowlist (depends on final entity/service names)
|
||||
- [x] Define the reverse-proxy path allowlist (depends on final entity/service names)
|
||||
- [ ] Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent)
|
||||
- [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
|
||||
validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`).
|
||||
@@ -420,9 +502,20 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||||
was left unexercised beyond confirming it registers without error at pyscript load time.
|
||||
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
|
||||
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
|
||||
2026-08-12 (see section B)
|
||||
- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
|
||||
- [ ] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360
|
||||
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
|
||||
list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`,
|
||||
`homeassistant/README.md`, the profile template)
|
||||
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the
|
||||
`umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns
|
||||
`None` on a missing or malformed file instead of raising; all 5 call sites
|
||||
(`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`,
|
||||
`tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no
|
||||
caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md`
|
||||
§"Was den Branch betrifft").
|
||||
- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
|
||||
2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard
|
||||
access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in
|
||||
DataMetric360.
|
||||
- [ ] Optional: persist the RAM-only states (trip start, fuel low-water-mark) — deliberately
|
||||
deferred; may become moot with the FMM003 switch
|
||||
- [ ] Upload vehicle photos to `www/bilder/`, set `steuer.faellig` (operational data, not code)
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
# DataMetric360 — Umsetzungsplan
|
||||
|
||||
**Stand: 2026-08-11.** Dieser Plan führt von heute (fertiges HA-Panel, fertige Datenschicht,
|
||||
halbfertiger Design-Entwurf) bis zur fertigen DataMetric360-App inklusive aller offenen Punkte aus
|
||||
`AGENTS.md`. Er ist bewusst kleinteilig geschrieben, damit jede Phase auch von einem schwächeren
|
||||
KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann.
|
||||
|
||||
**Beschlossene Rahmenbedingungen (nicht neu diskutieren):**
|
||||
- Alle Daten liegen in Home Assistant. Es gibt **kein eigenes Backend** — die App spricht direkt
|
||||
mit der HA-REST-/WebSocket-API und ruft die vorhandenen `pyscript.audi_dashboard_*`-Services auf.
|
||||
- **Kein Electron.** Fürs iPhone wird die Web-App mit **Capacitor** verpackt (nur Sideload, nie
|
||||
App Store). Optionaler Zwischenschritt: als PWA auf den Homescreen (Phase 10).
|
||||
- Die App ersetzt später das HA-Panel; bis dahin bleibt das Panel unangetastet in Betrieb.
|
||||
|
||||
---
|
||||
|
||||
## Für das ausführende Modell: Arbeitsregeln
|
||||
|
||||
1. **Lies zuerst:** `AGENTS.md` (Regeln + Stand), dann die Phase, die du bearbeitest — nichts
|
||||
anderes. Hole dir Detailwissen erst, wenn ein Schritt es verlangt (die Schritte nennen die
|
||||
Quelldatei).
|
||||
2. **Eine Phase pro Session.** Arbeite die Schritte in Reihenfolge ab. Jeder Schritt hat eine
|
||||
Prüfung („✅ Fertig wenn"). Erfülle sie, bevor du weitergehst. Wenn eine Prüfung fehlschlägt,
|
||||
behebe das, bevor du den nächsten Schritt beginnst.
|
||||
3. **Nach jeder abgeschlossenen Phase:** Haken in diesem Plan setzen, betroffene Checkboxen in
|
||||
`AGENTS.md` abhaken, „Last updated" in `AGENTS.md` aktualisieren (englisch!), committen mit
|
||||
deutscher Commit-Message.
|
||||
4. **Harte Verbote:**
|
||||
- Audi-Assets (Schriften, Ringe, Typenschilder) niemals nach `design-system/` kopieren oder in
|
||||
irgendetwas einbauen, das veröffentlicht/hochgeladen wird. Quelle für die App:
|
||||
`homeassistant/www/` und `design/uploads/`.
|
||||
- Keine Schriftgewichte ≥ 600, keine Schatten, keine Verläufe. Nur Gewichte 300/400.
|
||||
- `homeassistant/` nicht umbauen (nur die explizit genannten Pflege-Schritte in Phase 2).
|
||||
- Keine zusätzlichen npm-Pakete einführen, die der Plan nicht nennt, ohne es zu begründen und
|
||||
in `AGENTS.md` zu dokumentieren.
|
||||
- Home Assistant niemals direkt ins Internet stellen. Nur die in Phase 12 beschriebenen Wege.
|
||||
5. **Sprache:** Code-Bezeichner, Kommentare, UI-Texte, Commits auf Deutsch (Ausnahme: `AGENTS.md`
|
||||
englisch; `design-system/` behält englische Props).
|
||||
6. **Zahlenformat** immer `de-DE` (1.234,5). Übernimm die Helfer `de()`/`eur()` aus dem alten
|
||||
Panel (`homeassistant/www/audi-dashboard-app.js`), statt eigene zu erfinden.
|
||||
|
||||
## Phasenübersicht und Abhängigkeiten
|
||||
|
||||
| Phase | Inhalt | Hängt ab von | Braucht Hardware/Extern? |
|
||||
|---|---|---|---|
|
||||
| 1 | Arbeitsumgebung herstellen und verifizieren | — | nein |
|
||||
| 2 | Pflege HA-Panel (Doku-Drift, Härtung) | — | nein |
|
||||
| 3 | Design-Entwurf vervollständigen | — | Claude Design (Besitzer) |
|
||||
| 4 | Workspace + React-Gerüst in `companion-app/` | 1 | nein |
|
||||
| 5 | App-Shell: Theme, Router, responsives Layout | 4 | nein |
|
||||
| 6 | Onboarding + Datenanbindung + Offline-UX | 5 | nein |
|
||||
| 7 | Alle Screens umsetzen | 3, 6 | nein |
|
||||
| 8 | Audi-Assets einbauen | 7 | nein |
|
||||
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
|
||||
| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
|
||||
| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 7–10 | HA-Produktivinstanz |
|
||||
| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
|
||||
| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
|
||||
|
||||
Phasen 2, 3 und 12 (Teilschritte) sind unabhängig und können vorgezogen werden. Phase 13 ist die
|
||||
einzige, die zwingend auf Hardware wartet.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Arbeitsumgebung herstellen und verifizieren ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> Ergebnis: design-system baut und besteht 21/21, companion-app typecheckt und besteht 7/7.
|
||||
> Die verlorene Testinstanz wurde als reproduzierbares Skript neu aufgebaut — siehe
|
||||
> `testumgebung/` (Abweichung vom Plan, bewusst: das Original ging genau deshalb verloren,
|
||||
> weil es nur als Anleitung existierte).
|
||||
|
||||
**Ziel:** Beide npm-Pakete bauen, beide Smoke-Tests laufen, die HA-Testinstanz ist erreichbar.
|
||||
|
||||
1. Node-Version prüfen: `node --version` — muss ≥ 20 sein (companion-app nutzt
|
||||
`--experimental-strip-types`, ab Node 22 stabil; wenn < 20: mit nvm/Volta Node 22 installieren).
|
||||
2. `cd design-system && npm install && npm run build`
|
||||
✅ Fertig wenn: `design-system/dist/index.js` und `dist/styles.css` existieren, Build ohne Fehler.
|
||||
3. `npm run smoke` (im selben Ordner)
|
||||
✅ Fertig wenn: 21/21 Fälle „ok" melden.
|
||||
4. `cd ../companion-app && npm install && npm run typecheck`
|
||||
✅ Fertig wenn: `tsc --noEmit` fehlerfrei durchläuft.
|
||||
5. HA-Testinstanz prüfen: `curl -s -o /dev/null -w '%{http_code}' http://localhost:18123/api/` —
|
||||
erwartet: `401` (API da, Token fehlt — das ist richtig so). Wenn keine Antwort: Der
|
||||
Docker-Container heißt `audi_ha_test` (`docker start audi_ha_test`). Existiert er nicht, siehe
|
||||
`homeassistant/README.md` (Testbericht-Abschnitt) für den Aufbau; notfalls Phase-1-Schritt 6
|
||||
überspringen und in Phase 9 nachholen.
|
||||
6. `npm run smoke` in `companion-app/`
|
||||
✅ Fertig wenn: 7/7 Prüfungen grün.
|
||||
|
||||
**Abschluss Phase 1:** nichts committen (nur `node_modules`/`dist`, beides gitignored).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Pflege des bestehenden HA-Panels ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> Doku-Drift behoben, `profil_lesen()` gehärtet und an der Testinstanz belegt.
|
||||
> Offen bleibt nur, was Betriebsdaten sind: Fahrzeugfotos und `steuer.faellig`.
|
||||
|
||||
**Ziel:** Doku stimmt wieder mit dem Code überein; ein Crash-Risiko ist beseitigt. Reine Pflege —
|
||||
**keine** Funktionsänderungen.
|
||||
|
||||
1. **Statistik-Behauptung korrigieren** (3 Stellen, gleiche Falschaussage „zeigt Beispielzahlen"):
|
||||
- `homeassistant/www/audi-dashboard-app.js` Kopfkommentar Zeilen ~13–15
|
||||
- `homeassistant/README.md` Zeile ~99
|
||||
- `homeassistant/INSTALL.md` Zeile ~204
|
||||
Neue Aussage sinngemäß: „Die Statistik-Seite berechnet echte Werte aus Fahrten und
|
||||
Tankvorgängen (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)."
|
||||
2. **INSTALL.md Schritt 4** (Zeilen ~106–109): falsche Variablennamen ersetzen.
|
||||
Falsch: `DOORS_SENSOR`, `WINDOWS_SENSOR`, `LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`.
|
||||
Richtig (siehe `homeassistant/pyscript/modules/einstellungen.py`): `TUER_SENSOREN`,
|
||||
`FENSTER_SENSOREN`, `TUERSCHLOSS_SENSOREN` (je 4er-Listen) und `BATTERIE_SENSOR` (einzeln).
|
||||
3. **README.md:** Erwähnung der „97-%-Volltankungsregel" (~Zeile 110) streichen (Regel wurde
|
||||
entfernt, siehe Kommentar `pyscript/belegverarbeitung.py:18-20`); in der Dateiübersicht die
|
||||
fehlenden Skripte ergänzen: `tankerkennung.py`, `batterieverlauf.py`, `bilderverwaltung.py`,
|
||||
`backup.py`, `updateverwaltung.py`.
|
||||
4. **Obsoleten Kommentar entfernen:** `pyscript/belegverarbeitung.py:41` — den Zusatz
|
||||
„TODO: Datei ablegen" streichen (Datei existiert längst), Konstante selbst unverändert lassen.
|
||||
5. **`profil_lesen()` härten** (`pyscript/modules/profil.py`, ~Zeile 51–55): fehlende oder
|
||||
nicht parsebare `fahrzeugprofil.json` abfangen. Verhalten: Fehler ins Log (`log.error` ist in
|
||||
pyscript global verfügbar), Rückgabe `None`; Aufrufer prüfen. Vorher alle Aufrufer suchen
|
||||
(`grep -rn "profil_lesen" homeassistant/pyscript/`) und sicherstellen, dass jeder mit `None`
|
||||
umgehen kann — wo nicht, dort eine frühe Rückkehr einbauen. **pyscript-Eigenheit beachten:**
|
||||
Datei-I/O nur über `task.executor(io.open, …)`-Muster wie im Bestand, kein nacktes `open()`.
|
||||
✅ Fertig wenn: `python3 -c "import ast; ast.parse(open('homeassistant/pyscript/modules/profil.py').read())"`
|
||||
fehlerfrei ist und jeder Aufrufer den `None`-Fall behandelt.
|
||||
6. **Bewusst NICHT tun:** Swipe-Delete-Fallback, Popup-Tastaturzugang, Leaflet-Selbsthosting
|
||||
(Audit-Reste) — Entscheidung laut Audit: kommt in DataMetric360, nicht ins alte Panel.
|
||||
RAM-only-Zustände (Fahrtstart, Tank-Tiefststand) ebenfalls nicht anfassen — wird mit dem
|
||||
FMM003-Umstieg hinfällig.
|
||||
7. Committen: „Doku an Codestand angleichen und profil_lesen gegen fehlende Datei härten".
|
||||
`AGENTS.md`: die erledigten Punkte in Block C abhaken.
|
||||
|
||||
Offen bleiben in Block C nur: Fahrzeugfotos hochladen + `steuer.faellig` setzen (macht der
|
||||
Besitzer in der App/UI, kein Code).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Design-Entwurf vervollständigen ⏭️ ÜBERSPRUNGEN (2026-08-11)
|
||||
|
||||
> Entscheidung des Besitzers: Die Screens wurden direkt aus den Views des alten
|
||||
> Panels abgeleitet, statt auf den Claude-Design-Entwurf zu warten. Dieser
|
||||
> Abschnitt bleibt stehen, falls der Entwurf später auf den Stand der
|
||||
> Umsetzung gebracht werden soll.
|
||||
|
||||
**Ziel:** Der Entwurf zeigt das richtige Fahrzeug und alle Seiten, die das alte Panel hat.
|
||||
Diese Phase läuft im Claude-Design-Projekt
|
||||
(https://claude.ai/design/p/c28a8d4d-ec4e-4178-9e49-ab5b90c02097) — der Besitzer stößt sie an;
|
||||
ein Agent kann den Auftragstext vorbereiten und den Export danach einpflegen.
|
||||
|
||||
1. **Korrektur Modell:** überall „Audi RS 4 Avant competition" statt „RS 6 Avant"; Typenschild
|
||||
`rs4` (negative/positive) statt `rs6`.
|
||||
2. **Fehlende Seiten ergänzen** (16 Stück; Vorlage ist jeweils die View im alten Panel —
|
||||
Funktionsnamen aus `homeassistant/www/audi-dashboard-app.js`, Beschreibung in
|
||||
`SPECIFICATION.md` §3 „Views"):
|
||||
| # | Seite | Vorlage (alte View) |
|
||||
|---|---|---|
|
||||
| 1 | Fahrzeugstatus-Detail (12-Punkte Türen/Fenster/Schlösser) | `vSicherheit()` |
|
||||
| 2 | Fahrzeugdaten/Technik/Ausstattung | `vIdent()` |
|
||||
| 3 | Batterieverlauf (SVG-Kurve, SoC/SoH) | `vBatterieverlauf()` |
|
||||
| 4 | Service (Termine, Terminanfrage, Servicebuch-Liste) | `vService()` |
|
||||
| 5 | Werkstatt bearbeiten | `vWerkstatt()` |
|
||||
| 6 | Servicebuch-Eintrag (ansehen/bearbeiten/neu) | `vSbuch(i)` |
|
||||
| 7 | Versicherung & Steuer (Hub) | `vVers()` |
|
||||
| 8 | Beitrag bearbeiten | `vBeitrag()` |
|
||||
| 9 | Vertragsdetails | `vVertragsdetails()` |
|
||||
| 10 | Schutzbrief (nur lesen) | `vSchutz()` |
|
||||
| 11 | Notrufnummern bearbeiten | `vNotrufBearbeiten()` |
|
||||
| 12 | Kfz-Steuer (Ansicht + Bearbeiten) | `vSteuer()`/`vSteuerBearbeiten()` |
|
||||
| 13 | Reifen (Sätze, km-Zähler, Wechsel, Drehmoment) | `vReifen()` |
|
||||
| 14 | Fahrt-Detail (Karte, Daten, Bearbeiten) | `vTrip(id)` |
|
||||
| 15 | Tankvorgang-Detail (inkl. Beleg-Upload/-Ersetzen) | `vFill(id)` |
|
||||
| 16 | Einstellungen in voller Tiefe (Fahrzeug-Setup, Fotos, SmartDeal, Pausenzeit, Export/Import, Backup, Version/Update) | `vEinst()` |
|
||||
Für jede Seite beide Layoutvarianten (schmal mit Tab-Leiste unten, breit ≥1000px mit
|
||||
Seitenleiste) — wie im Brief `DESIGN_BRIEF_DATAMETRIC360.md` gefordert.
|
||||
3. **Export einpflegen:** kompletten Ordner `design/` durch den neuen Export ersetzen
|
||||
(`design/README.md` sagt selbst: „wird beim nächsten Export komplett ersetzt"), README-Abschnitt
|
||||
„Stand dieses Exports" aktualisieren.
|
||||
✅ Fertig wenn: `grep -ci "rs 6\|rs6" design/DM360.dc.html` = 0 und alle 16 Seiten im Export
|
||||
auffindbar sind.
|
||||
4. Committen; `AGENTS.md` Block A Punkt 1 abhaken.
|
||||
|
||||
**Wichtig:** Phase 7 kann pro Screen schon vor Abschluss dieser Phase beginnen — die 8 vorhandenen
|
||||
Hauptscreens sind entworfen; die 16 Unterseiten folgen dann dem gleichen Muster.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Workspace und React-Gerüst ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
**Ziel:** `companion-app/` ist ein lauffähiges Vite+React+TS-Projekt, das `@audi-dash/ui` als
|
||||
echte Dependency nutzt. Die bestehende Datenschicht (`src/api/`) bleibt unverändert liegen.
|
||||
|
||||
1. **npm-Workspace-Root anlegen** — neue Datei `package.json` im Repo-Root:
|
||||
```json
|
||||
{
|
||||
"name": "audi-app",
|
||||
"private": true,
|
||||
"workspaces": ["design-system", "companion-app"]
|
||||
}
|
||||
```
|
||||
Dazu Repo-Root-`.gitignore` um `node_modules/` ergänzen (falls nicht abgedeckt).
|
||||
2. **Dependencies in `companion-app/package.json` ergänzen** (bestehende Felder beibehalten!):
|
||||
`dependencies`: `react ^18`, `react-dom ^18`, `@audi-dash/ui` (Versionsangabe `*` — kommt über
|
||||
den Workspace); `devDependencies` zusätzlich: `vite`, `@vitejs/plugin-react`,
|
||||
`@types/react`, `@types/react-dom`. Scripts ergänzen: `"dev": "vite"`,
|
||||
`"build": "vite build"`, `"preview": "vite preview"`.
|
||||
3. `npm install` **im Repo-Root** ausführen (verlinkt design-system in die companion-app).
|
||||
Danach einmal `npm run build --workspace design-system` (die App importiert aus `dist/`).
|
||||
4. **Vite-Konfiguration** `companion-app/vite.config.ts`:
|
||||
```ts
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
build: { outDir: "dist", target: "es2022" },
|
||||
})
|
||||
```
|
||||
5. **tsconfig anpassen:** Das bestehende `companion-app/tsconfig.json` ist auf die reine
|
||||
Datenschicht zugeschnitten (`noEmit`, sehr streng — so lassen). Ergänzen: `"jsx": "react-jsx"`,
|
||||
`"lib": ["ES2022", "DOM", "DOM.Iterable"]` und `"moduleResolution": "bundler"`, falls nicht
|
||||
gesetzt. Die strengen Flags (`exactOptionalPropertyTypes` usw.) NICHT abschwächen.
|
||||
6. **Einstieg anlegen:**
|
||||
- `companion-app/index.html` — Minimal-HTML mit `<div id="wurzel"></div>` und
|
||||
`<script type="module" src="/src/main.tsx"></script>`; `<html lang="de">`;
|
||||
`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`.
|
||||
- `companion-app/src/main.tsx`:
|
||||
```tsx
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "@audi-dash/ui/styles.css"
|
||||
import { App } from "./App"
|
||||
createRoot(document.getElementById("wurzel")!).render(<App />)
|
||||
```
|
||||
- `companion-app/src/App.tsx` — vorerst nur:
|
||||
```tsx
|
||||
export function App() {
|
||||
return <div className="ads-root" data-theme="nacht" style={{ minHeight: "100vh" }}>DataMetric360</div>
|
||||
}
|
||||
```
|
||||
**Pflicht:** Das äußerste Element trägt immer `className="ads-root"` und `data-theme` — ohne
|
||||
diese Wurzelklasse greifen die Design-Tokens nicht (Regel aus
|
||||
`design-system/.design-sync/conventions.md`).
|
||||
7. ✅ Fertig wenn: `npm run dev --workspace companion-app` startet, http://localhost:5173 zeigt
|
||||
dunklen Hintergrund (`#161b23`) mit Text; `npm run typecheck --workspace companion-app` und
|
||||
`npm run build --workspace companion-app` laufen fehlerfrei.
|
||||
8. Committen: „companion-app: Vite+React-Gerüst und Workspace-Anbindung an @audi-dash/ui".
|
||||
`AGENTS.md` Block A Punkt 2 abhaken, Statustabelle aktualisieren.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — App-Shell: Theme, Navigation, responsives Layout ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
**Ziel:** Navigationsgerüst, das beide Layouts beherrscht (schmal: Tab-Leiste unten; breit ≥1000px:
|
||||
Seitenleiste), mit Tag/Nacht-Theme und Zurück-Navigation. Noch ohne echte Daten.
|
||||
|
||||
**Vorbild für Verhalten:** das alte Panel (`audi-dashboard-app.js`) — flacher Routenzustand,
|
||||
`ZURUECK`-Tabelle. Vorbild fürs Aussehen: `design/DM360.dc.html` (dort existiert der
|
||||
`wide`-Umschalter mit 240px-Seitenleiste bereits).
|
||||
|
||||
1. **Kein Router-Paket installieren.** Eigener flacher Zustand wie im alten Panel:
|
||||
`src/navigation.ts` mit `type Route = { name: RouteName; id?: string }`, React-State im
|
||||
App-Root, `geheZu(name, id?)`, plus `ZURUECK`-Tabelle (aus dem alten Panel übernehmen und um
|
||||
die neuen Routen ergänzen; 3-stufige Verschachtelung `schutz → vertragsdetails → vers` beachten).
|
||||
2. **Theme:** `src/theme.ts` — Zustand `"nacht" | "tag"`, Persistenz in `localStorage`
|
||||
(Schlüssel `dm360.theme`), Initialwert: gespeicherter Wert, sonst `prefers-color-scheme`,
|
||||
Standard `nacht`. Gesetzt wird ausschließlich `data-theme` auf dem `ads-root`-Element.
|
||||
`localStorage`-Zugriffe in try/catch (Muster aus dem alten Panel, Private-Mode-Browser).
|
||||
3. **Layout:** `src/Shell.tsx` — CSS-Grid/Flex mit Breakpoint bei 1000px
|
||||
(`window.matchMedia("(min-width: 1000px)")` + Listener):
|
||||
- schmal: Inhalt + `TabBar`-Komponente (aus `@audi-dash/ui`) unten, `env(safe-area-inset-*)`
|
||||
als Padding (iPhone-Notch).
|
||||
- breit: 240px-Seitenleiste links (Navigationspunkte + Ringe-Platzhalter oben), Inhalt rechts;
|
||||
Listen-Detail nebeneinander wo der Entwurf es vorsieht.
|
||||
Die 5 Hauptbereiche: Übersicht, Mein Audi, Fahrten, Statistik, Tanken. Einstellungen über
|
||||
Zahnrad oben rechts (nicht in der Tab-Leiste) — wie im alten Panel und im Entwurf.
|
||||
4. **Platzhalter-Screens:** je Hauptbereich eine Datei unter `src/screens/` (z. B.
|
||||
`Uebersicht.tsx`), die vorerst nur Titel per `ads-eyebrow`/`Tile` zeigt.
|
||||
5. ✅ Fertig wenn: Im Browser (a) bei < 1000px die Tab-Leiste unten erscheint und alle 5 Bereiche
|
||||
wechselbar sind, (b) bei ≥ 1000px stattdessen die Seitenleiste erscheint, (c) der Theme-Wechsel
|
||||
sichtbar Tag/Nacht umschaltet und einen Reload überlebt.
|
||||
6. Committen: „companion-app: App-Shell mit responsivem Layout, Navigation und Theme".
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Onboarding, Datenanbindung, Offline-UX ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
**Ziel:** Die App verbindet sich echt mit Home Assistant über die vorhandene Datenschicht.
|
||||
|
||||
**Die Datenschicht ist fertig — nur benutzen, nicht neu bauen.** Einstieg:
|
||||
`companion-app/src/api/index.ts` (`DataMetricApi`). Sie liefert: REST-Reads
|
||||
(`profilLesen`, `fahrtenLesen`, `tankvorgaengeLesen`, `fahrzeugstatusLesen`), Live-Updates
|
||||
(`HassLive` mit Reconnect), Schreibzugriffe über die Offline-Queue (`profilSchreiben`,
|
||||
`belegHochladen`, `fahrtLoeschen`, `tankvorgangLoeschen`, `jetztAktualisieren`) und
|
||||
Zugangsdaten-Verwaltung (`umgebung.ts`).
|
||||
|
||||
1. **Ersteinrichtungs-Screen** (`src/screens/Einrichtung.tsx`) nach dem Entwurf in
|
||||
`design/DM360.dc.html` (dort der `isSetup`-Block): Felder Server-Adresse + Zugangs-Token,
|
||||
Knopf „Verbinden" ruft `verbindungPruefen()` aus der Datenschicht; Fehler unterscheiden nach
|
||||
`ApiFehler.istAnmeldeproblem` (Token falsch) vs. `istNetzproblem` (Adresse/Netz) und in
|
||||
verständlichem Deutsch anzeigen. QR-Knopf: vorerst ausblenden oder deaktiviert mit Hinweis
|
||||
(kommt mit Capacitor, Phase 10). Bei Erfolg Zugangsdaten über die `Ablage` der Datenschicht
|
||||
speichern und in die App wechseln.
|
||||
2. **Datenkontext:** `src/datenkontext.tsx` — ein React-Context, der beim Start `DataMetricApi`
|
||||
instanziiert, initial alle vier Reads lädt, sich bei `aufZustand()` für Live-Updates
|
||||
registriert und `{profil, fahrten, tankvorgaenge, status, verbindung}` bereitstellt.
|
||||
**Übernahme-Pflicht aus dem alten Panel:** die Umrechnungen `profilZuConfig()`/`profilZuCar()`
|
||||
(inkl. SmartDeal-Gültigkeit: aktiv wenn `laeuft_ab` fehlt oder ≥ heute, Ablaufdatum
|
||||
**einschließlich**) aus `audi-dashboard-app.js` nach TypeScript portieren — Logik exakt
|
||||
beibehalten.
|
||||
3. **Offline-UX** (Entwurf: Offline-Marker in der Kopfzeile):
|
||||
- Verbindungszustand aus `aufVerbindung()` der Datenschicht → dezenter „Offline"-Hinweis im
|
||||
Kopf, Daten bleiben sichtbar.
|
||||
- Warteschlange aus `aufAenderung()` → sichtbare Zahl wartender Änderungen („2 Änderungen
|
||||
warten auf Übertragung"), wie im Design-Brief gefordert.
|
||||
4. **Stale-Anzeige:** `last_updated` des Fahrzeugstatus mitführen (Muster: Staleness-Indikator des
|
||||
alten Panels).
|
||||
5. ✅ Fertig wenn: Gegen `audi_ha_test` (Token siehe Phase 9 Schritt 1): Einrichtung mit falschem
|
||||
Token zeigt Anmeldefehler, mit richtigem Token lädt die App Profil + Status; HA-Container
|
||||
stoppen → Offline-Marker erscheint; Container starten → verschwindet wieder (Reconnect testet
|
||||
die Backoff-Logik der Datenschicht).
|
||||
6. Committen; `AGENTS.md` Block A Punkte Onboarding/Offline teilweise abhaken (QR bleibt offen).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Alle Screens umsetzen ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> Alle 21 Seiten stehen. Die Entwurfsvorlage kam für die 16 Unterseiten aus den
|
||||
> Views des alten Panels statt aus Claude Design (Entscheidung des Besitzers,
|
||||
> 2026-08-11) — Phase 3 ist damit gegenstandslos, solange der Entwurf nicht
|
||||
> nachgezogen werden soll.
|
||||
|
||||
**Ziel:** Funktionsgleichheit mit dem alten Panel plus die neuen Live-Ansichten. Pro Screen:
|
||||
Entwurf aus `design/` nachbauen (mit `@audi-dash/ui`-Komponenten), Logik aus der alten View
|
||||
portieren, an den Datenkontext anschließen.
|
||||
|
||||
**Reihenfolge und Zuordnung** (Logik-Vorlage = Funktion in `audi-dashboard-app.js`;
|
||||
Datenquelle = Feld im Datenkontext):
|
||||
|
||||
| Screen | Logik-Vorlage | Datenquelle / Services | Hinweise |
|
||||
|---|---|---|---|
|
||||
| Übersicht | `vHome()` | status, profil, fahrten, tankvorgaenge | Foto, Status „sicher abgestellt", Reichweite, km, nächster Service, Teaser letzte Fahrt/Tankung; Pull-to-Refresh → `jetztAktualisieren()` |
|
||||
| Fahrten-Liste | `vTrips()` | fahrten | Jahr→Monat-Akkordeon (`Accordion`); Lösch-Aktion: **nicht nur Swipe** — zusätzlich „…"-Menü pro Zeile (behebt Audit-Finding barrierefreies Löschen); manuelles Anlegen (`vTripsFormular()`) über Service `audi_dashboard_fahrt_manuell_anlegen` |
|
||||
| Fahrt-Detail | `vTrip(id)` | fahrten | Karte: Leaflet **lokal bündeln** (`npm i leaflet`, Audit-Finding CDN), Tile-Layer nach Theme; **kein `fakeTrack()` portieren** — ohne echte Route nur Start/Ende-Marker zeigen, mit ehrlichem Leerhinweis |
|
||||
| Tanken-Liste | `vFuel()` | tankvorgaenge | volumengewichteter Durchschnittspreis; Lösch-„…"-Menü wie bei Fahrten |
|
||||
| Tankvorgang-Detail | `vFill(id)` | tankvorgaenge | Beleg-Upload: Datei → Base64 → `belegHochladen()`; Ergebnis kommt asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis` (Muster im alten Panel: `belegErgebnisVerarbeiten()`) |
|
||||
| Statistik | `vStat()` | fahrten, tankvorgaenge | Rechenfunktionen (`fahrtenSeit`, `tankSeit`, `verbrauch`, `istNachtZeit`) 1:1 portieren |
|
||||
| Mein Audi (Hub) | `vAudi()` | profil | Fotogalerie + Kachel-Links auf die Unterseiten |
|
||||
| Fahrzeugstatus-Detail | `vSicherheit()` | status | 16-Punkte-Liste; `ok === null` heißt „unbekannt", nie grün raten |
|
||||
| Fahrzeugdaten | `vIdent()` | profil (technik, ausstattung) | statisch aus dem Profil |
|
||||
| Batterieverlauf | `vBatterieverlauf()` | eigener Read der Entität `pyscript.audi_dashboard_batterieverlauf` | SVG-Chart portieren (Pinch/Pan/Tap); Leerzustand prominent — Sensor ist derzeit unbesetzt |
|
||||
| Service + Werkstatt + Servicebuch | `vService()`, `vWerkstatt()`, `vSbuch()` | profil.service | Öl-/Service-Prognose (`oelwechselPrognose()`, `intervalle()`, `termine()`) exakt portieren; ICS-Export (`ics()`-Helfer) übernehmen |
|
||||
| Versicherung/Steuer-Gruppe | `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`, `vNotrufBearbeiten()`, `vSteuer()` | profil.versicherung, profil.steuer | reine Formular-/Anzeige-Seiten; Schreiben immer als ganzes Profil über `profilSchreiben()` (es gibt keinen Feld-einzeln-Service!) |
|
||||
| Reifen | `vReifen()` | profil.reifen + Entitäten `pyscript.reifen_*` | Wechsel über Service `audi_dashboard_reifen_wechseln` (nur `"sommer"`/`"winter"`); Wechseldatum → ICS |
|
||||
| Einstellungen | `vEinst()` | profil, alle | volle Tiefe: Fahrzeug-Setup, Theme, Fotos (Upload über `audi_dashboard_bild_hochladen`, nur die 10 erlaubten Dateinamen!), SmartDeal, Pausenzeit, Profil-Export/Import, Backup, CSV-Export, Version |
|
||||
| **Neu: Live-Fahrt** | — (neu) | status (künftig FMM003-Felder) | Entwurf `screen === 'live'`: Karte, Geschwindigkeit groß, Strecke/Dauer; bis Phase 13 hinter einem Feature-Schalter `LIVE_VERFUEGBAR = false` in einer zentralen Konfigurationsdatei — Screen bauen, Schalter erst mit echten Daten umlegen |
|
||||
| Leerzustände | — | — | für Fahrten/Tanken/Statistik je einen freundlichen „noch keine Daten"-Zustand (Design-Brief), wird real gebraucht: beide JSONL sind aktuell leer |
|
||||
|
||||
**Regeln für alle Screens:**
|
||||
- Nur `@audi-dash/ui`-Komponenten + Tokens (`var(--…)`), keine neuen Hex-Farben, keine neuen
|
||||
Komponenten ohne sie zuerst in `design-system/` anzulegen (dann dort mit CSS+Preview, danach
|
||||
hier verwenden).
|
||||
- Destruktive Aktionen immer mit Bestätigung; Fehler sichtbar in rot inline (Muster altes Panel).
|
||||
- Jede Zahl durch `de()`/`eur()`.
|
||||
- Nach jedem Screen: `npm run typecheck` + Sichtprüfung schmal UND breit.
|
||||
|
||||
✅ Fertig wenn: jeder Screen der Tabelle existiert, mit Testdaten befüllbar ist und in beiden
|
||||
Layouts bedienbar aussieht wie der Entwurf. Committen pro Screen-Gruppe (z. B. „Fahrten-Screens"),
|
||||
nicht ein Riesencommit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Audi-Assets einbauen ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
**Ziel:** Die App sieht aus wie das Original — Schriften, Ringe, Typenschilder.
|
||||
|
||||
1. Quellen: Schriften aus `design/uploads/*.ttf` (oder die woff2-Base64-Blöcke aus
|
||||
`homeassistant/www/audi-dashboard.css`), Ringe `design/assets/audi-rings-{white,black}.svg`,
|
||||
Typenschilder aus `homeassistant/www/badges/` (alle 14).
|
||||
2. Ablage: `companion-app/src/assets/audi/` + `@font-face`-Deklarationen in einer eigenen
|
||||
`src/audi-schrift.css` (Familien exakt wie im Bestand: `"Audi Type"` 400, `"Audi Type Wide"`
|
||||
300/400, `"Audi Type Extended"` italic). Der `--font-stack` wird per CSS-Variable auf dem
|
||||
`ads-root` überschrieben — `design-system/`-Dateien bleiben unberührt.
|
||||
3. Typenschild-Auswahl nach Modell aus dem Profil (Logik aus `vEinst()`/Badge-Mapping des alten
|
||||
Panels übernehmen; Theme-abhängig positive/negative-Variante).
|
||||
4. **Kontrolle:** `git log --stat` des Commits zeigt KEINE Änderung unter `design-system/`;
|
||||
Gewichte ≥ 600 kommen nirgends vor (`grep -rn "font-weight" companion-app/src | grep -vE "300|400"`
|
||||
liefert nichts).
|
||||
5. Committen; `AGENTS.md` Block A Assets-Punkt abhaken. Hinweis im Commit: Repo bleibt privat
|
||||
(Lizenz).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Tests ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> 90 Unit- und Rendertests, dazu 9 Prüfungen gegen die laufende Instanz.
|
||||
|
||||
**Ziel:** Die ungeteste Hälfte der Datenschicht ist abgedeckt; die Kern-Rechenlogik hat Unit-Tests.
|
||||
|
||||
1. **Token für die Testinstanz erzeugen:** `audi_ha_test` im Browser öffnen
|
||||
(http://localhost:18123), anmelden, Profil → Sicherheit → langlebiges Zugriffstoken erstellen.
|
||||
Token NUR in `companion-app/.env.local` ablegen (`VITE_TEST_TOKEN=…`), Datei in `.gitignore`
|
||||
aufnehmen. **Niemals committen.**
|
||||
2. **Authentifizierter Smoke** (`scripts/smoke-auth.ts`, Aufbau wie `scripts/smoke.ts`): mit Token
|
||||
(a) `zustandLesen("pyscript.audi_dashboard_profil")` liefert Daten, (b) `dienstAufrufen` mit
|
||||
einem harmlosen Service (`audi_dashboard_jetzt_aktualisieren`) gibt 200, (c) Queue-Round-Trip:
|
||||
Eintrag einstellen bei gestopptem Container, Container starten, Queue leert sich. Script in
|
||||
`package.json` als `smoke:auth`; ohne gesetztes Token bricht es mit klarer Meldung ab
|
||||
(Exit-Code 0, „übersprungen").
|
||||
3. **Unit-Tests** mit `vitest` (einzige neue Dev-Dependency): portierte Rechenlogik testen —
|
||||
Statistik (`verbrauch`, `istNachtZeit`, Zeiträume), Service-Prognose (`oelwechselPrognose`:
|
||||
Neustart nach Ölwechsel, 180-Tage-Gewichtung, Kappung), SmartDeal-Gültigkeit (Ablauftag
|
||||
einschließlich!), `de()`/`eur()`-Formatierung. Testdaten als Fixtures aus realistischen
|
||||
JSONL-Zeilen (Schema: `SPECIFICATION.md` §5).
|
||||
4. ✅ Fertig wenn: `npm run smoke:auth` 3/3 grün (mit Token), `npx vitest run` grün, und beide in
|
||||
`companion-app/README.md` dokumentiert sind.
|
||||
5. Committen; `AGENTS.md` Block A Test-Punkt abhaken.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
|
||||
> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
|
||||
> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
|
||||
> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
|
||||
> angeschlossene iPhone und die Apple-ID des Besitzers.
|
||||
|
||||
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
|
||||
(Keychain + QR-Scan).
|
||||
|
||||
**Stufe 1 — PWA (kein neues Werkzeug):**
|
||||
1. `companion-app/public/manifest.webmanifest` (Name „DataMetric360", `display: "standalone"`,
|
||||
Themenfarbe `#161b23`, neutrale Icons 192/512px — NICHT die Audi-Ringe als App-Icon, der
|
||||
Homescreen ist „außen"; neutrales DM360-Monogramm erzeugen) + Verweis im `index.html`.
|
||||
2. Erreichbarkeit fürs Handy: solange Phase 12 nicht fertig ist, über Tailscale
|
||||
(`npm run build`, Ausgabe z. B. über HA `/local/` oder `vite preview --host` im LAN testen).
|
||||
3. ✅ Fertig wenn: „Zum Home-Bildschirm" auf dem iPhone die App randlos startet und Login +
|
||||
Datenanzeige funktionieren.
|
||||
|
||||
**Stufe 2 — Capacitor:**
|
||||
4. `npm i -D @capacitor/cli && npm i @capacitor/core @capacitor/ios @capacitor/android`
|
||||
(im Workspace für companion-app), `npx cap init DataMetric360 app.datametric360 --web-dir dist`,
|
||||
`npx cap add ios`, `npx cap add android`.
|
||||
5. **Sichere Token-Ablage:** Capacitor-Secure-Storage-Plugin (z. B.
|
||||
`@aparajita/capacitor-secure-storage`) als neue `Ablage`-Implementierung; über den vorhandenen
|
||||
Hook `ablageSetzen()` der Datenschicht einhängen, wenn `umgebungErkennen()` „capacitor" meldet.
|
||||
Browser-Fall bleibt unverändert `BrowserAblage`.
|
||||
6. **QR-Scan:** Barcode-Plugin (`@capacitor-mlkit/barcode-scanning`) + Kamera-Berechtigung;
|
||||
Gegenstück: kleine statische Seite unter HA `/local/dm360-qr.html`, die den Token **clientseitig**
|
||||
(Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt. Inhalt des QR: JSON
|
||||
`{"url": "...", "token": "..."}`. Wenn das zusammen > 1 Tag Aufwand wird: weglassen —
|
||||
manuelles Einfügen ist die beschlossene, ausreichende Lösung.
|
||||
7. **iOS-Sideload:** `npx cap open ios`, in Xcode Signing mit eigener Apple-ID, aufs Gerät bauen.
|
||||
⚠️ Entscheidungspunkt für den Besitzer: mit kostenlosem Apple-Konto läuft die Signatur nach
|
||||
**7 Tagen** ab (App neu aufspielen); ein bezahltes Entwicklerkonto (99 €/Jahr) macht 1 Jahr.
|
||||
Bei 7-Tage-Schmerz ist die PWA-Stufe die Alltagslösung, Capacitor das Extra für Keychain/QR.
|
||||
8. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und
|
||||
neu installieren → Token weg; Backup/Restore-Verhalten notieren), QR-Einrichtung funktioniert
|
||||
oder ist dokumentiert entfallen.
|
||||
9. Committen; `AGENTS.md` Block A Punkte QR/Secure-Storage abhaken.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — HA-Einbettung und Ablösung des alten Panels
|
||||
|
||||
**Ziel:** Die neue App läuft im HA-Seitenmenü; das alte Panel ist archiviert.
|
||||
|
||||
1. **Build in HA ausliefern:** `npm run build`, Ausgabe nach `/config/www/dm360/` der
|
||||
Produktivinstanz (Weg analog `update.ps1`; das Script um diesen Ordner erweitern).
|
||||
2. `configuration.yaml`: `panel_iframe`-Eintrag (Titel „Mein Audi", `mdi:car-sports`,
|
||||
URL `/local/dm360/index.html`). Der `panel_custom`-Block des alten Panels bleibt zunächst
|
||||
parallel bestehen (zweiter Menüpunkt „Mein Audi (alt)").
|
||||
3. **Parallelbetrieb mindestens 2 Wochen:** beide Panels zeigen dieselben Daten (gleiche
|
||||
Entitäten/Services — Abweichungen sind Portierungsfehler; prüfen: Übersichtszahlen, eine
|
||||
Fahrt anlegen/löschen, ein Beleg-Upload).
|
||||
4. **Ablösung:** alten `panel_custom`-Block entfernen; Dateien NICHT löschen, sondern
|
||||
verschieben: `homeassistant/www/audi-dashboard-app.js` → `homeassistant/archiv/` (plus
|
||||
Panel-Stub und CSS), README-Hinweis dort ablegen. Beschlossene Regel: **archivieren, nie
|
||||
löschen** (`COMPANION_APP_ARCHITECTURE.md` §1).
|
||||
5. ✅ Fertig wenn: Neues Panel im HA-Menü, altes entfernt aber archiviert, `SPECIFICATION.md`
|
||||
bekommt eine Kopfnotiz „beschreibt das archivierte Panel; aktuell ist companion-app/".
|
||||
6. Committen; `AGENTS.md` Block D abhaken, Statustabelle umstellen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy 🟡 VORBEREITET
|
||||
|
||||
> Fertige Freigabeliste samt Prüfbefehlen: `homeassistant/REVERSE_PROXY.md`.
|
||||
> Ausführung braucht Cloudflare-Konto und die Nameserver-Umstellung.
|
||||
|
||||
**Ziel:** Die App funktioniert von unterwegs (Mobilfunk), ohne dass HA selbst erreichbar ist.
|
||||
Referenz: `COMPANION_APP_ARCHITECTURE.md` §4/§5. Teilweise Besitzer-Aufgaben (Konten/DNS).
|
||||
|
||||
1. **[Besitzer] Nameserver umstellen:** `datametric360.app` bei all-inkl auf die
|
||||
Cloudflare-Nameserver zeigen lassen (Cloudflare-Konto → Site hinzufügen → „Full setup";
|
||||
Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.app` die
|
||||
Cloudflare-Server liefert.
|
||||
2. **Hostnamen festlegen** (Empfehlung aus dem Architekturdokument übernehmen): App unter
|
||||
`https://datametric360.app`, API unter `https://api.datametric360.app` — getrennter
|
||||
API-Hostname hält die Allowlist übersichtlich. Entscheidung in `AGENTS.md` protokollieren.
|
||||
3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, zwei öffentliche Hostnamen:
|
||||
`datametric360.app` → Reverse-Proxy-Port (statische App-Dateien + nichts weiter),
|
||||
`api.datametric360.app` → Reverse-Proxy-Port (API-Pfade). Kein Router-Port wird geöffnet.
|
||||
4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (Standard-Empfehlung; Traefik nur, falls NPM
|
||||
an Grenzen stößt — Entscheidung dokumentieren). Allowlist ausschließlich:
|
||||
- `GET /api/states/pyscript.audi_dashboard_*` und `GET /api/states/pyscript.reifen_*`
|
||||
- `POST /api/services/pyscript/audi_dashboard_*`
|
||||
- `GET /api/` (Verbindungsprüfung) und der WebSocket-Pfad `/api/websocket`
|
||||
Alles andere (v. a. `/auth`, `/lovelace`, `/config`, `/api/config`) wird geblockt.
|
||||
⚠️ Ehrliche Einschränkung dokumentieren: `/api/websocket` lässt sich nicht pfadgenau
|
||||
beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht bleibt der LLAT
|
||||
(ohne Token keine Verbindung); das ist die im Architekturdokument akzeptierte Abwägung.
|
||||
5. **Prüfen von außen** (Mobilfunk, VPN aus): App lädt, Login klappt, `curl` auf einen
|
||||
Nicht-Allowlist-Pfad (z. B. `/auth/authorize`) liefert 403/404; HA-Port 8123 ist von außen
|
||||
nicht erreichbar.
|
||||
6. ✅ Fertig wenn: Schritt 5 vollständig besteht. Die endgültige Allowlist wortwörtlich in
|
||||
`COMPANION_APP_ARCHITECTURE.md` §5.1 eintragen (dort als offener Punkt vorgemerkt) und in
|
||||
`AGENTS.md` Block B abhaken.
|
||||
|
||||
Schritte 2–4 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden.
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi
|
||||
|
||||
> **Diese Phase ist gegenstandslos.** Die FMM003-Hardware ist seit 2026-08-13 in Betrieb, aber über
|
||||
> den in `AGENTS.md` (§ FMM003/Datenpfad) beschriebenen Weg: **flespi (natives Codec8/TCP, IMEI-
|
||||
> Auth), nicht MQTT/Mosquitto.** Die Schritte unten (eigene CA, Mosquitto-Add-on, Zertifikate,
|
||||
> `mosquitto_sub`-Mitschnitt) beschreiben die zuerst geplante, dann verworfene Variante — stehen
|
||||
> nur noch als Entscheidungsprotokoll. `homeassistant/FMM003_MAPPING.md` (unten referenziert)
|
||||
> wurde beim Merge 2026-08-13 deshalb bewusst nicht übernommen. Maßgeblich ist ausschließlich
|
||||
> `AGENTS.md`.
|
||||
|
||||
**Ziel (nicht mehr aktuell):** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über
|
||||
die Zündung. Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder
|
||||
simulieren.**
|
||||
|
||||
1. **Mosquitto-Add-on** in HA installieren, Benutzer für das Gerät anlegen.
|
||||
2. **Zertifikate:** kleine eigene CA (drei `openssl`-Befehle: CA-Schlüssel+Zertifikat,
|
||||
Server-Zertifikat für den Broker, Client-Zertifikat fürs Gerät); Broker auf 8883/TLS;
|
||||
CA-/Client-Dateien im Teltonika Configurator unter *Security* hinterlegen. Ohne Zertifikate
|
||||
verweigert der FMM003 MQTT.
|
||||
3. **[Besitzer] Broker-Erreichbarkeit entscheiden:** Portfreigabe 8883 (einfach; TLS+Login
|
||||
davor) **oder** VPS-Broker mit Mosquitto-Bridge über Tailscale (keine Freigabe, mehr Aufwand).
|
||||
Entscheidung + Begründung in `COMPANION_APP_ARCHITECTURE.md` §5.2a und `AGENTS.md` nachtragen.
|
||||
4. **Gerät konfigurieren:** Firmware-Version notieren (Codec JSON ist firmwareabhängig!);
|
||||
*System → Data Protocol → Codec JSON*; *GPRS → MQTT* mit Broker-IP/Port/Login.
|
||||
5. **Erste echte Nachricht mitschneiden:** `mosquitto_sub -h <broker> -p 8883 --cafile ca.crt
|
||||
-u <nutzer> -P <pw> -t '#' -v > mitschnitt.txt` — mit Zündung an/aus, kurzer Fahrt.
|
||||
Aus dem Mitschnitt die **Feldzuordnungstabelle** bauen (JSON-Feld → Bedeutung → Einheit) und
|
||||
als `homeassistant/FMM003_MAPPING.md` einchecken (ohne echte Positionsdaten im Beispiel!).
|
||||
6. **HA-MQTT-Integration:** aus der Tabelle `mqtt:`-Sensoren/`device_tracker` in der
|
||||
HA-Konfiguration definieren (Zündung, Geschwindigkeit, Drehzahl, Tankfüllstand, Position, km).
|
||||
7. **Fahrterkennung umstellen:** neues pyscript `fahrterkennung_fmm003.py` nach dem Muster von
|
||||
`fahrterkennung.py` — Trigger ist die Zündungs-Entität (1→0 mit Pausentoleranz via
|
||||
`task.unique()`/`task.sleep()`, Ende-Zeitpunkt auf den echten Zündung-aus-Moment rückdatieren;
|
||||
`fahrten_pausenzeit_min` aus dem Profil weiterverwenden). Erst parallel laufen lassen
|
||||
(schreibt in eine Test-Datei), vergleichen, dann scharf schalten und
|
||||
`sensor.iphone_wifi_connection` aus `einstellungen.py` entfernen.
|
||||
8. **Live-Screen aktivieren:** Feature-Schalter aus Phase 7 umlegen, Live-Felder anbinden;
|
||||
GPS-Route der laufenden Fahrt aufzeichnen und im Fahrt-Detail echte Tracks statt der
|
||||
Start/Ende-Marker zeigen (damit stirbt der letzte Rest von `fakeTrack()` endgültig).
|
||||
9. ✅ Fertig wenn: eine echte Fahrt automatisch erkannt, mit km und Route gespeichert und in der
|
||||
App (Liste, Detail mit echter Route, Statistik) korrekt angezeigt wird; VAG-Integration und
|
||||
WLAN-Trigger sind entfernt; `AGENTS.md` Block B komplett abgehakt.
|
||||
|
||||
---
|
||||
|
||||
## Anhang A — Nachschlagereferenz für das ausführende Modell
|
||||
|
||||
**Entitäten (lesen):** `pyscript.audi_dashboard_profil`, `…_fahrten`, `…_tankvorgaenge`,
|
||||
`…_fahrzeugstatus`, `…_batterieverlauf`, `…_beleg_ergebnis`, `…_update_status`,
|
||||
`pyscript.reifen_sommer_km`, `…_winter_km`, `…_aktiver_satz`. Nutzdaten stets im Attribut `daten`.
|
||||
|
||||
**Services (schreiben, Domain `pyscript`):** `audi_dashboard_profil_schreiben`,
|
||||
`…_beleg_hochladen`, `…_tankvorgang_manuell`, `…_tankvorgang_aktualisieren`,
|
||||
`…_tankvorgang_loeschen`, `…_fahrt_manuell_anlegen`, `…_fahrt_loeschen`, `…_bild_hochladen`,
|
||||
`…_bild_loeschen`, `…_reifen_wechseln`, `…_backup_jetzt`, `…_backup_wiederherstellen`,
|
||||
`…_jetzt_aktualisieren`, `…_screening_jetzt`, `…_update_pruefen`, `…_update_installieren`.
|
||||
Signaturen: `SPECIFICATION.md` §5. **Service-Aufrufe geben keine Werte zurück** — Ergebnisse
|
||||
kommen asynchron über Entitäten (Muster: Beleg-Upload).
|
||||
|
||||
**Stolperfallen:**
|
||||
- Profil wird immer **als Ganzes** geschrieben — nie Teil-Updates erfinden.
|
||||
- `edited_fields` eines Datensatzes schützt Felder vor automatischen Überschreibungen — beim
|
||||
Portieren von Bearbeiten-Formularen beibehalten.
|
||||
- Sensorwerte `"unknown"`/`"unavailable"` = `None` behandeln, nie raten (Muster
|
||||
`zustand_oder_none()`).
|
||||
- pyscript: kein nacktes `open()`/`with` — `task.executor`-Muster aus `modules/profil.py`.
|
||||
- HA-Attribute haben ~16 KB-Grenze (`frontend_veroeffentlichung.py`) — keine großen Blobs in
|
||||
Entitäten stopfen.
|
||||
- `fahrten.jsonl`/`tankvorgaenge.jsonl` sind aktuell **leer** — Leerzustände sind der erste
|
||||
Eindruck der App, nicht ein Randfall.
|
||||
|
||||
## Anhang B — Definition of Done (gesamt)
|
||||
|
||||
Die App gilt als fertig, wenn: alle Screens aus Phase 7 in beiden Layouts funktionieren; Onboarding,
|
||||
Offline-Queue und Beleg-Upload gegen die Produktiv-HA laufen; die App als PWA und (falls Capacitor
|
||||
umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.app`
|
||||
funktioniert, ohne dass HA exponiert ist; das alte Panel archiviert ist; und `AGENTS.md` den
|
||||
Endstand widerspiegelt (alle Blöcke A–D abgehakt oder begründet gestrichen).
|
||||
@@ -2,3 +2,9 @@ node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
|
||||
# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
|
||||
# getrackt.
|
||||
ios/
|
||||
android/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli"
|
||||
|
||||
/**
|
||||
* Capacitor-Konfiguration für die native Hülle.
|
||||
*
|
||||
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
|
||||
* ausschließlich für diese eine private Installation freigegeben.
|
||||
*
|
||||
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
|
||||
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
|
||||
*/
|
||||
const konfiguration: CapacitorConfig = {
|
||||
appId: "app.datametric360",
|
||||
appName: "DataMetric360",
|
||||
webDir: "dist",
|
||||
server: {
|
||||
// https, damit die App als sicherer Ursprung gilt — sonst verweigern
|
||||
// Browser-Bausteine wie die Zwischenablage ihren Dienst.
|
||||
androidScheme: "https",
|
||||
iosScheme: "https",
|
||||
},
|
||||
ios: {
|
||||
// Die App zeichnet bis unter die Statusleiste und hält den sicheren
|
||||
// Bereich selbst über env(safe-area-inset-*) frei.
|
||||
contentInset: "never",
|
||||
backgroundColor: "#161b23",
|
||||
},
|
||||
android: {
|
||||
backgroundColor: "#161b23",
|
||||
},
|
||||
plugins: {
|
||||
// Leitet fetch() über die native HTTP-Schicht statt über die WebView.
|
||||
//
|
||||
// Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
|
||||
// liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
|
||||
// Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
|
||||
// ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
|
||||
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
|
||||
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
|
||||
CapacitorHttp: { enabled: true },
|
||||
},
|
||||
}
|
||||
|
||||
export default konfiguration
|
||||
@@ -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>
|
||||
@@ -2,14 +2,38 @@
|
||||
"name": "datametric360",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.",
|
||||
"description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Datenschicht plus Oberfläche auf Basis von @audi-dash/ui.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "node --experimental-strip-types scripts/smoke.ts"
|
||||
"smoke": "node --experimental-strip-types scripts/smoke.ts",
|
||||
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@audi-dash/ui": "*",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/node": "^24.13.3",
|
||||
"typescript": "^5.7.2"
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -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 |
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Fahrzeugbild mit Platzhalter.
|
||||
*
|
||||
* Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit
|
||||
* `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string`
|
||||
* nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert
|
||||
* es hier einmal.
|
||||
*/
|
||||
|
||||
import { ImagePlaceholder } from "@audi-dash/ui"
|
||||
|
||||
import { bildUrl } from "./bilder"
|
||||
|
||||
export function Bild({
|
||||
datei,
|
||||
beschreibung,
|
||||
name,
|
||||
groesse,
|
||||
}: {
|
||||
datei: string | null | undefined
|
||||
beschreibung: string
|
||||
name: string
|
||||
groesse?: "default" | "mini" | "round"
|
||||
}) {
|
||||
const url = bildUrl(datei)
|
||||
// Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
|
||||
// Miniaturansicht liefe er über den Rand hinaus.
|
||||
const mitDateiname = groesse !== "mini" && groesse !== "round"
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
{...(url ? { src: url } : {})}
|
||||
{...(groesse ? { size: groesse } : {})}
|
||||
{...(mitDateiname && datei ? { hint: datei } : {})}
|
||||
alt={beschreibung}
|
||||
label={name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Statistik. Vorlage: `vStat()` im alten Panel — die Rechenregeln stecken in
|
||||
* `daten/statistik.ts` und sind von dort unverändert übernommen.
|
||||
*/
|
||||
|
||||
import { Fig, ProgressBar, StatGrid, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
|
||||
import { datum, de, deOderStrich, eur } from "../format"
|
||||
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
|
||||
|
||||
export function Statistik() {
|
||||
const { fahrten, tankvorgaenge, einstellungen } = useDaten()
|
||||
if (!einstellungen) return null
|
||||
|
||||
if (fahrten.length === 0 && tankvorgaenge.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Noch nichts auszuwerten"
|
||||
text="Sobald die ersten Fahrten und Tankungen erfasst sind, entstehen hier Auswertungen zu Strecke, Zeit, Verbrauch und Kosten."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const a = jahresauswertung(
|
||||
fahrten,
|
||||
tankvorgaenge,
|
||||
einstellungen.nachtVon,
|
||||
einstellungen.nachtBis,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dm-raster">
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
|
||||
<Fig value={de(a.kmJahr)} unit="km" size={40} />
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].km),
|
||||
unit: "km",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
/>
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1),
|
||||
unit: "h",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Getankte Menge und Kosten {a.jahr}</span>
|
||||
<Fig value={de(a.literJahr)} unit="l" size={40} />
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].liter),
|
||||
unit: "l",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
/>
|
||||
<StatGrid
|
||||
items={ZEITRAEUME.map((z) => ({
|
||||
value: de(a.jeZeitraum[z].kosten),
|
||||
unit: "€",
|
||||
caption: ZEITRAUM_NAME[z],
|
||||
}))}
|
||||
columns={4}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile className="dm-raster__breit">
|
||||
<span className="ads-eyebrow">Langzeitverbrauch</span>
|
||||
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Getankt insgesamt" wert={`${de(a.langzeit.liter, 1)} l`} />
|
||||
<Wertzeile
|
||||
label="Strecke bis zum letzten Tankstop"
|
||||
wert={`${de(a.langzeit.km)} km`}
|
||||
zusatz={a.langzeit.basis !== "–" ? a.langzeit.basis : undefined}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Letzter Tankstop"
|
||||
wert={a.langzeit.bis ? datum(a.langzeit.bis) : "—"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{a.langzeit.unplausibel && (
|
||||
<p className="dm-fussnote dm-fussnote--warnung">
|
||||
Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
|
||||
Kilometerstände vollständig vorliegen, erscheint er hier.
|
||||
</p>
|
||||
)}
|
||||
<p className="dm-fussnote">
|
||||
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
|
||||
Tankstop — nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
|
||||
ergibt, sobald eine Fahrt fehlt.
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Tag und Nacht {a.jahr}</span>
|
||||
<Fig
|
||||
value={a.kmJahr > 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
|
||||
unit="% bei Tag"
|
||||
size={40}
|
||||
/>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.tagKm, color: "var(--fg)", label: `Tag · ${de(a.tagKm)} km` },
|
||||
{ value: a.nachtKm, color: "var(--fg3)", label: `Nacht · ${de(a.nachtKm)} km` },
|
||||
]}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
|
||||
</p>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Art der Fahrten {a.jahr}</span>
|
||||
<Fig
|
||||
value={a.kmJahr > 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"}
|
||||
unit="% Arbeitsweg"
|
||||
size={40}
|
||||
/>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
|
||||
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
|
||||
]}
|
||||
/>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile label="Fahrten Arbeitsweg" wert={de(a.arbeitFahrten)} />
|
||||
<Wertzeile label="Fahrten privat" wert={de(a.privatFahrten)} />
|
||||
<Wertzeile
|
||||
label={`Kraftstoffkosten ${a.jahr}`}
|
||||
wert={`${eur(a.kostenJahr)} €`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Modell-Typenschild und Vier-Ringe-Zeichen.
|
||||
*
|
||||
* Beide wechseln mit dem Erscheinungsbild: auf dunklem Grund die negative,
|
||||
* auf hellem die positive Fassung. Es gibt nur die Modelle, für die ein
|
||||
* Typenschild vorliegt — für alles andere zeigt die App nichts statt eines
|
||||
* falschen Schildes.
|
||||
*/
|
||||
|
||||
import ringeHell from "../assets/audi/audi-rings-white.svg"
|
||||
import ringeDunkel from "../assets/audi/audi-rings-black.svg"
|
||||
import { useTheme } from "../theme"
|
||||
|
||||
const SCHILDER = import.meta.glob<string>("../assets/audi/typenschilder/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?url",
|
||||
})
|
||||
|
||||
/**
|
||||
* Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
|
||||
* darüber hinaus im Namen steht.
|
||||
*
|
||||
* Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
|
||||
* steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
|
||||
* competition"). Sonst stünde „RS 4" zweimal da.
|
||||
*/
|
||||
export function schildErkennen(
|
||||
titel: string,
|
||||
): { kuerzel: string; zusatz: string } | null {
|
||||
const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel)
|
||||
if (!treffer?.[1]) return null
|
||||
const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase()
|
||||
return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() }
|
||||
}
|
||||
|
||||
export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
|
||||
const [theme] = useTheme()
|
||||
const erkannt = schildErkennen(modell)
|
||||
if (!erkannt) return null
|
||||
|
||||
const variante = theme === "nacht" ? "negative" : "positive"
|
||||
const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
|
||||
if (!quelle) return null
|
||||
|
||||
return <img src={quelle} alt={modell} height={hoehe} style={{ height: hoehe, width: "auto" }} />
|
||||
}
|
||||
|
||||
export function VierRinge({ hoehe = 20 }: { hoehe?: number }) {
|
||||
const [theme] = useTheme()
|
||||
return (
|
||||
<img
|
||||
src={theme === "nacht" ? ringeHell : ringeDunkel}
|
||||
alt="Audi"
|
||||
height={hoehe}
|
||||
style={{ height: hoehe, width: "auto" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Übersicht — die Startseite. Vorlage: `vHome()` im alten Panel.
|
||||
*
|
||||
* Fahrzeugbild, Status „sicher abgestellt", Reichweite und Tankfüllstand,
|
||||
* Kilometerstand, nächster Service, Vorschau auf letzte Fahrt und letzte
|
||||
* Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an.
|
||||
*/
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { Typenschild, schildErkennen } from "./Typenschild"
|
||||
|
||||
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
|
||||
useDaten()
|
||||
const [aktualisiert, setzeAktualisiert] = useState(false)
|
||||
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
|
||||
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
|
||||
const schild = schildErkennen(einstellungen.fahrzeugtitel)
|
||||
const ausfuehrung = einstellungen.ausfuehrung.trim()
|
||||
// Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition"
|
||||
// plus Ausführung „competition") — dann nicht doppelt anhängen.
|
||||
const zusatz = [
|
||||
schild?.zusatz,
|
||||
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
|
||||
? ausfuehrung
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim()
|
||||
|
||||
const letzteFahrt = fahrten
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
|
||||
const letzteTankung = tankvorgaenge
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0]
|
||||
|
||||
const sicherheitsstatus =
|
||||
fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"
|
||||
const sicherheitstext =
|
||||
fahrzeug.gesichert === true
|
||||
? "Sicher abgestellt"
|
||||
: fahrzeug.gesichert === false
|
||||
? "Nicht vollständig verschlossen"
|
||||
: "Zustand unbekannt"
|
||||
|
||||
const aktualisieren = async () => {
|
||||
setzeAktualisiert(true)
|
||||
try {
|
||||
await jetztAktualisieren()
|
||||
} finally {
|
||||
setzeAktualisiert(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-szene">
|
||||
<Bild
|
||||
datei={einstellungen.startbild}
|
||||
beschreibung={einstellungen.fahrzeugtitel}
|
||||
name="Fahrzeugbild"
|
||||
/>
|
||||
<div className="dm-szene__text">
|
||||
<div className="dm-szene__marke">
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
|
||||
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
|
||||
Schild tritt der volle Name an dessen Stelle. */}
|
||||
<div className="ads-title dm-szene__zusatz">
|
||||
{schild ? (
|
||||
zusatz && <span className="ads-sport">{zusatz}</span>
|
||||
) : (
|
||||
<>
|
||||
{einstellungen.fahrzeugtitel}{" "}
|
||||
{zusatz && <span className="ads-sport">{zusatz}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="ads-eyebrow">
|
||||
{einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusRow
|
||||
status={sicherheitsstatus}
|
||||
title={sicherheitstext}
|
||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||
onClick={() => geheZu("sicherheit")}
|
||||
/>
|
||||
|
||||
<div className="dm-raster">
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Reichweite</span>
|
||||
<Fig value={deOderStrich(fahrzeug.reichweite)} unit="km" size={40} />
|
||||
<ProgressBar percent={Math.max(0, Math.min(100, fahrzeug.tankPct))} />
|
||||
<div className="dm-tanktext">
|
||||
<span>{de(fahrzeug.tankPct)} % im Tank</span>
|
||||
<span>
|
||||
rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "}
|
||||
{de(einstellungen.tankvolumen)} l
|
||||
</span>
|
||||
</div>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Kilometerstand</span>
|
||||
<Fig
|
||||
value={fahrzeug.odoBekannt ? de(fahrzeug.odo) : "—"}
|
||||
unit="km"
|
||||
size={40}
|
||||
/>
|
||||
<Werteliste
|
||||
kinder={
|
||||
<>
|
||||
<Wertzeile
|
||||
label="Nächster Ölwechsel"
|
||||
wert={fahrzeug.oelwechselFaelligTs ? datum(fahrzeug.oelwechselFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.oelwechselFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.oelwechselFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Wertzeile
|
||||
label="Nächste Inspektion"
|
||||
wert={fahrzeug.inspektionFaelligTs ? datum(fahrzeug.inspektionFaelligTs) : "—"}
|
||||
zusatz={
|
||||
fahrzeug.inspektionFaelligKm != null
|
||||
? `in ${de(Math.abs(fahrzeug.inspektionFaelligKm))} km`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
<div className="dm-raster">
|
||||
{letzteFahrt ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Fahrt"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteFahrt.distance_km, 1)} km
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Fahrt</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Fahrt erfasst"
|
||||
text="Sobald du losfährst, erscheint hier die letzte Fahrt."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
{letzteTankung ? (
|
||||
<NaviKachel
|
||||
titel="Letzte Tankung"
|
||||
wert={
|
||||
<>
|
||||
<div className="dm-navikachel__zahl">
|
||||
{deOderStrich(letzteTankung.liters, 2)} l
|
||||
</div>
|
||||
<small>
|
||||
{datum(letzteTankung.ts)} ·{" "}
|
||||
{letzteTankung.fuel_total_eur != null
|
||||
? `${eur(letzteTankung.fuel_total_eur)} €`
|
||||
: "Betrag offen"}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
beiKlick={() => geheZu("fill", letzteTankung.tank_id)}
|
||||
/>
|
||||
) : (
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Letzte Tankung</span>
|
||||
<Leerzustand
|
||||
titel="Noch keine Tankung erfasst"
|
||||
text="Tankvorgänge erkennt die App automatisch am steigenden Füllstand — oder du trägst sie selbst ein."
|
||||
/>
|
||||
</Tile>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dm-aktionen">
|
||||
<button
|
||||
type="button"
|
||||
className="dm-textknopf"
|
||||
onClick={() => void aktualisieren()}
|
||||
disabled={aktualisiert}
|
||||
>
|
||||
{aktualisiert ? "Frage das Fahrzeug …" : "Jetzt beim Fahrzeug nachfragen"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche.
|
||||
* Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui.
|
||||
*/
|
||||
|
||||
.dm-spalte {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Kopfzeile */
|
||||
|
||||
.dm-kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.dm-kopf__titel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck,
|
||||
.dm-kopf__aktion {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-kopf__zurueck:hover,
|
||||
.dm-kopf__aktion:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-kopf__aktion[aria-current="page"] {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Inhaltsfläche */
|
||||
|
||||
.dm-inhalt {
|
||||
flex: 1;
|
||||
padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
|
||||
Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0,
|
||||
sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt
|
||||
die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst.
|
||||
Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */
|
||||
.dm-inhalt > * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dm-inhalt--breit {
|
||||
padding: var(--sp-5) 32px 32px;
|
||||
}
|
||||
|
||||
/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt
|
||||
untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */
|
||||
.dm-raster {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.dm-raster {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dm-raster--drei {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dm-raster__breit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */
|
||||
.dm-zweispalter {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.dm-zweispalter {
|
||||
grid-template-columns: minmax(0, 4fr) minmax(0, 5fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- Seitenleiste */
|
||||
|
||||
.dm-seitenleiste {
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 24px 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__marke {
|
||||
padding: 0 10px var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 11px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-func);
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__punkt--an {
|
||||
background: var(--tile);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__fuss {
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als
|
||||
Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */
|
||||
.dm-brotkrume {
|
||||
padding: var(--sp-4) 32px 0;
|
||||
}
|
||||
|
||||
.dm-brotkrume button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.dm-brotkrume button:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Tab-Leiste */
|
||||
|
||||
.dm-tableiste {
|
||||
flex: none;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.dm-seitenleiste__marke {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 10px var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-szene__marke {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/*
|
||||
* Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste,
|
||||
* Ersteinrichtung, Leerzustände, Ladeanzeige.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------- Eingabefelder */
|
||||
|
||||
.dm-eingabe,
|
||||
.dm-auswahl {
|
||||
font: inherit;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
min-width: 120px;
|
||||
max-width: 60%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dm-eingabe::placeholder {
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-auswahl {
|
||||
text-align: right;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-auswahl option {
|
||||
color: var(--fg);
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
.dm-notiz {
|
||||
font: inherit;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
color: var(--fg);
|
||||
background: var(--tile-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-func);
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Hinweisleiste */
|
||||
|
||||
.dm-hinweis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 9px var(--sp-5);
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + 9px);
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
background: var(--tile-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-hinweis--offline {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dm-hinweis svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Ersteinrichtung */
|
||||
|
||||
.dm-einrichtung {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dm-einrichtung__karte {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.dm-einrichtung__titel {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.dm-einrichtung__text {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--fg2);
|
||||
margin: 0 0 22px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dm-einrichtung__fehler {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--bad);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.dm-einrichtung__knoepfe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-einrichtung__hinweis {
|
||||
font-size: 11.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--fg3);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Leer- und Ladezustände */
|
||||
|
||||
.dm-leer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-leer__titel {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-leer__text {
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dm-laden {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 44px 24px;
|
||||
color: var(--fg2);
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Übersicht */
|
||||
|
||||
.dm-szene {
|
||||
position: relative;
|
||||
border-radius: var(--r-tile);
|
||||
overflow: hidden;
|
||||
background: var(--tile);
|
||||
}
|
||||
|
||||
.dm-szene .ads-bild,
|
||||
.dm-szene img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dm-szene__text {
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.dm-tanktext {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-navikachel__wert {
|
||||
margin-top: var(--sp-1);
|
||||
font-size: 15px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-navikachel__zahl {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dm-navikachel__wert small,
|
||||
.dm-wertzeile small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-aktionen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-textknopf {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--r-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-textknopf:hover:not(:disabled) {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-textknopf:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Werteliste */
|
||||
|
||||
.dm-werteliste {
|
||||
margin: var(--sp-3) 0 0;
|
||||
}
|
||||
|
||||
.dm-wertzeile {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-wertzeile:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dm-wertzeile dt {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-wertzeile dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dm-abschnitt {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 2px 0;
|
||||
}
|
||||
|
||||
.dm-fussnote {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--fg3);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Fahrzeugstatus */
|
||||
|
||||
.dm-pruefliste {
|
||||
list-style: none;
|
||||
margin: var(--sp-3) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dm-pruefliste__zeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-pruefliste__zeile:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dm-pruefliste__label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dm-pruefliste__wert {
|
||||
font-size: 12.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-punkt {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dm-punkt--ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dm-punkt--offen {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot,
|
||||
damit "keine Meldung" nie wie eine Aussage aussieht. */
|
||||
.dm-punkt--unbekannt {
|
||||
border: 1.5px solid var(--fg3);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Galerie */
|
||||
|
||||
.dm-galerie {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Listenzeilen */
|
||||
|
||||
.dm-zeile {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dm-zeile__wisch {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn
|
||||
waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
|
||||
.dm-zeile__menue {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg3);
|
||||
border-radius: var(--r-func);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-zeile__menue:hover {
|
||||
background: var(--shade);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.dm-listenzeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
padding: 12px 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-listenzeile__haupt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dm-listenzeile__titel {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dm-listenzeile__neben {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-listenzeile__unter {
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--fg3);
|
||||
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
|
||||
wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
|
||||
die man sucht. */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Detailseiten */
|
||||
|
||||
.dm-detail__kopf {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-2) 0 var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dm-detail__zahl {
|
||||
font-size: 40px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dm-detail__einheit {
|
||||
font-size: 15px;
|
||||
color: var(--fg2);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.dm-detail__marken {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dm-karte {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
border-radius: var(--r-func);
|
||||
overflow: hidden;
|
||||
margin-top: var(--sp-3);
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Meldungen */
|
||||
|
||||
.dm-fehler {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.dm-erfolg {
|
||||
margin: var(--sp-3) 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.dm-fussnote--warnung {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Listen, Text */
|
||||
|
||||
.dm-liste {
|
||||
list-style: none;
|
||||
margin: var(--sp-2) 0 0;
|
||||
padding: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dm-liste li {
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dm-liste li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.72em;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg3);
|
||||
}
|
||||
|
||||
.dm-liste__serie {
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.dm-diagramm {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
margin: var(--sp-3) 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dm-knopfreihe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.dm-waehlen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.dm-waehlen__knopf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 13.5px;
|
||||
font-weight: 300;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dm-waehlen__knopf:hover {
|
||||
background: var(--shade);
|
||||
}
|
||||
|
||||
/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht
|
||||
aus dem Blick geraten. */
|
||||
.dm-speicherleiste {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
background: var(--canvas);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-szene__zusatz {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
|
||||
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
|
||||
.dm-szene__zusatz {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
|
||||
damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung
|
||||
innerhalb des Rahmens bleibt. */
|
||||
.dm-galerie {
|
||||
grid-template-columns: repeat(auto-fill, 78px);
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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"],
|
||||
},
|
||||
})
|
||||
@@ -509,9 +509,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -613,9 +610,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -630,9 +624,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -647,9 +638,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -664,9 +652,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -681,9 +666,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -698,9 +680,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -715,9 +694,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -732,9 +708,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -749,9 +722,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -766,9 +736,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -783,9 +750,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -800,9 +764,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -817,9 +778,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -930,6 +888,7 @@
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1066,6 +1025,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -1278,6 +1238,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1356,6 +1317,7 @@
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -1613,6 +1575,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +53,8 @@ def _backup_jetzt():
|
||||
|
||||
def _letztes_backup_vermerken():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
|
||||
profil.profil_schreiben(p)
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
@@ -69,6 +71,8 @@ def audi_dashboard_backup_jetzt():
|
||||
@time_trigger("cron(0 4 * * *)")
|
||||
def _automatisches_backup():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
|
||||
heute = datetime.date.today()
|
||||
faellig = (
|
||||
|
||||
@@ -38,7 +38,7 @@ import einstellungen
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # TODO: Datei ablegen
|
||||
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Hilfsfunktionen
|
||||
|
||||
@@ -42,6 +42,8 @@ _fahrt_start_ts = None
|
||||
|
||||
def _pausenzeit_sekunden():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return 15 * 60
|
||||
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
|
||||
|
||||
|
||||
|
||||