From ab2c3db679419e7bb46ebcc41001f43c08b17505 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:39:45 +0200 Subject: [PATCH 01/18] Umsetzungsplan mit 13 Phasen anlegen und in AGENTS.md verlinken Schrittweiser Plan fuer alle offenen Punkte inkl. Abnahmekriterien je Schritt, Nachschlagereferenz und Definition of Done. Ergaenzt die Entscheidungen vom 2026-08-11: kein Electron (Capacitor bzw. PWA) und kein eigenes Backend (HA-API direkt). --- AGENTS.md | 15 +- UMSETZUNGSPLAN.md | 562 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 UMSETZUNGSPLAN.md diff --git a/AGENTS.md b/AGENTS.md index 45448a4..e9c8c7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,10 +66,12 @@ 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, barely 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 @@ -172,6 +174,11 @@ capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), fac ## 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; diff --git a/UMSETZUNGSPLAN.md b/UMSETZUNGSPLAN.md new file mode 100644 index 0000000..1050526 --- /dev/null +++ b/UMSETZUNGSPLAN.md @@ -0,0 +1,562 @@ +# 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 | Mac mit Xcode, iPhone | +| 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 + +**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 (Quick Wins, Block C aus AGENTS.md) + +**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 (Claude Design) + +**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 in `companion-app/` + +**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 `
` und + ``; ``; + ``. + - `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() + ``` + - `companion-app/src/App.tsx` — vorerst nur: + ```tsx + export function App() { + return
DataMetric360
+ } + ``` + **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, Router, responsives Layout + +**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 + +**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 + +**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 + +**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 + +**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 (iPhone/Android, nur Sideload) + +**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 + +**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 (wartet auf Hardware) + +**Ziel:** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über die Zündung. +Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder simulieren.** + +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 -p 8883 --cafile ca.crt + -u -P -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). From 982bb04c1a258e26b3a49cd690fc15896a42c293 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:44:50 +0200 Subject: [PATCH 02/18] Phase 1: reproduzierbare HA-Testumgebung als Skript Die urspruengliche Wegwerf-Testinstanz war verloren, weil sie nur als Anleitung existierte. testumgebung/aufsetzen.sh baut sie jetzt vollstaendig neu auf: Container, Nutzer, langlebiges Token, pyscript als Custom Component, Projektdateien und nachgebildete Fahrzeugsensoren als Template-Entitaeten, die ueber Helfer zur Laufzeit steuerbar sind. Verifiziert: pyscript veroeffentlicht alle acht Entitaeten, Fahrzeugsensoren liefern Werte, beide Smoke-Tests gruen. --- UMSETZUNGSPLAN.md | 7 +- design-system/package-lock.json | 47 +--------- testumgebung/README.md | 67 ++++++++++++++ testumgebung/aufsetzen.sh | 84 +++++++++++++++++ testumgebung/einrichten.mjs | 102 ++++++++++++++++++++ testumgebung/konfiguration.yaml | 159 ++++++++++++++++++++++++++++++++ 6 files changed, 423 insertions(+), 43 deletions(-) create mode 100644 testumgebung/README.md create mode 100755 testumgebung/aufsetzen.sh create mode 100644 testumgebung/einrichten.mjs create mode 100644 testumgebung/konfiguration.yaml diff --git a/UMSETZUNGSPLAN.md b/UMSETZUNGSPLAN.md index 1050526..4cc674f 100644 --- a/UMSETZUNGSPLAN.md +++ b/UMSETZUNGSPLAN.md @@ -62,7 +62,12 @@ einzige, die zwingend auf Hardware wartet. --- -## Phase 1 — Arbeitsumgebung herstellen und verifizieren +## 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. diff --git a/design-system/package-lock.json b/design-system/package-lock.json index b3f1b31..3b4a58d 100644 --- a/design-system/package-lock.json +++ b/design-system/package-lock.json @@ -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" diff --git a/testumgebung/README.md b/testumgebung/README.md new file mode 100644 index 0000000..6543dd8 --- /dev/null +++ b/testumgebung/README.md @@ -0,0 +1,67 @@ +# testumgebung/ — Wegwerf-Home-Assistant für Entwicklung und Tests + +Setzt eine vollständige Home-Assistant-Instanz in Docker auf, in der das +pyscript-Backend dieses Projekts echt läuft: eigene Entitäten, echte Service-Aufrufe, +nachgebildete Fahrzeugsensoren. Gedacht als Ziel für die Abnahmekriterien aus +`../UMSETZUNGSPLAN.md` (Phasen 1, 6, 9). + +Die ursprüngliche Testinstanz aus der Entwicklung des HA-Panels war eine +Wegwerfinstanz ohne Skript und ist verloren gegangen — deshalb liegt der Aufbau +hier als ausführbares Skript statt als Anleitung. + +## Aufsetzen + +```bash +./aufsetzen.sh +``` + +Das Skript ist mehrfach ausführbar (löscht einen vorhandenen Container vorher). +Es lädt das offizielle Image, startet den Container auf **Port 18123**, legt per +API einen Testnutzer an, erzeugt ein langlebiges Zugriffstoken, installiert +pyscript als Custom Component, kopiert `../homeassistant/{pyscript,www,data}` +hinein und startet neu. + +Am Ende schreibt es das Token nach `../companion-app/.env.local` +(Variable `VITE_TEST_TOKEN`, gitignored) — von dort holen es die +authentifizierten Tests. + +**Zugang:** http://localhost:18123 · Benutzer `test` · Passwort `testtest123` + +## Was nachgebildet wird + +`konfiguration.yaml` bildet die Entitäten der HACS-Integration +`TommiG1/HA_VAG-EU-Data-Act` als Template-Sensoren nach, damit das Backend echte +Werte sieht. Steuerbar zur Laufzeit über Helfer — damit lassen sich die +`@state_trigger`-Abläufe wirklich auslösen: + +| Helfer | Steuert | Wofür | +|---|---|---| +| `input_number.test_kilometerstand` | `sensor.audi_rs_4_avant_mileage` | Reifenzähler, Fahrtabschluss-Screening | +| `input_number.test_tankfuellstand` | `sensor.audi_rs_4_avant_fuel_level` | automatische Tankerkennung | +| `input_number.test_reichweite` | `sensor.audi_rs_4_avant_range_primary` | Übersichtsanzeige | +| `input_text.test_wlan` | `sensor.iphone_wifi_connection` | Fahrterkennung (Start/Ende) | +| `input_boolean.test_tuer_vl` | Tür vorne links | „sicher abgestellt" | +| `input_boolean.test_fenster_vl` | Fenster vorne links | „sicher abgestellt" | +| `input_boolean.test_entriegelt` | Türschloss vorne links | „sicher abgestellt" | + +Beispiel — Fahrt auslösen (WLAN des Fahrzeugs verbinden, dann trennen): + +```bash +T=$(grep VITE_TEST_TOKEN ../companion-app/.env.local | cut -d= -f2) +curl -X POST -H "Authorization: Bearer $T" -H "Content-Type: application/json" \ + -d '{"entity_id":"input_text.test_wlan","value":"Audi_MMI"}' \ + http://localhost:18123/api/services/input_text/set_value +``` + +Der WLAN-Name muss zu `fahrzeug.wlan_ssid` in +`audi_dashboard/fahrzeugprofil.json` passen. + +## Aufräumen + +```bash +docker rm -f audi_ha_test +``` + +Die Konfiguration liegt außerhalb des Repos unter dem im Skript gesetzten +`ZIEL`-Pfad; es werden keine echten Fahrzeugdaten verwendet, sondern +`fahrzeugprofil.example.json`. diff --git a/testumgebung/aufsetzen.sh b/testumgebung/aufsetzen.sh new file mode 100755 index 0000000..20b1006 --- /dev/null +++ b/testumgebung/aufsetzen.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Setzt die Wegwerf-Testinstanz auf. Mehrfach ausfuehrbar. +# Siehe README.md im selben Ordner. +set -euo pipefail + +HIER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJEKT="$(dirname "$HIER")" +NAME="audi_ha_test" +PORT="18123" +ZIEL="${AUDI_TEST_CONFIG:-${TMPDIR:-/tmp}/audi-ha-test-config}" +BASIS="http://localhost:${PORT}" + +echo "==> Zielordner: $ZIEL" +rm -rf "$ZIEL" +mkdir -p "$ZIEL" + +echo "==> Alten Container entfernen (falls vorhanden)" +docker rm -f "$NAME" >/dev/null 2>&1 || true + +echo "==> Image laden" +docker pull ghcr.io/home-assistant/home-assistant:stable + +echo "==> Container starten" +docker run -d --name "$NAME" -p "${PORT}:8123" -v "$ZIEL:/config" \ + ghcr.io/home-assistant/home-assistant:stable >/dev/null + +echo "==> Auf Home Assistant warten" +for _ in $(seq 1 60); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$BASIS/" || true)" != "000" ]; then + break + fi + sleep 5 +done + +echo "==> Nutzer anlegen und Token erzeugen" +TOKEN="$(node "$HIER/einrichten.mjs" "$BASIS")" +if [ -z "$TOKEN" ]; then + echo "FEHLER: kein Token erhalten" >&2 + exit 1 +fi + +echo "==> pyscript installieren" +VERSION="$(curl -s https://api.github.com/repos/custom-components/pyscript/releases/latest \ + | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)" +TMPDL="$(mktemp -d)" +curl -sL -o "$TMPDL/pyscript.zip" \ + "https://github.com/custom-components/pyscript/archive/refs/tags/${VERSION}.zip" +unzip -oq "$TMPDL/pyscript.zip" -d "$TMPDL" +mkdir -p "$ZIEL/custom_components" +cp -r "$TMPDL/pyscript-${VERSION}/custom_components/pyscript" "$ZIEL/custom_components/" +rm -rf "$TMPDL" +echo " pyscript $VERSION" + +echo "==> Projektdateien einspielen" +cp "$HIER/konfiguration.yaml" "$ZIEL/configuration.yaml" +cp -r "$PROJEKT/homeassistant/pyscript" "$ZIEL/pyscript" +mkdir -p "$ZIEL/www" "$ZIEL/audi_dashboard" +cp -r "$PROJEKT/homeassistant/www/." "$ZIEL/www/" +cp "$PROJEKT/homeassistant/data/fahrzeugprofil.example.json" \ + "$ZIEL/audi_dashboard/fahrzeugprofil.json" +cp "$PROJEKT/homeassistant/data/shell_beleg_parser.py" "$ZIEL/audi_dashboard/" +: > "$ZIEL/audi_dashboard/fahrten.jsonl" +: > "$ZIEL/audi_dashboard/tankvorgaenge.jsonl" +printf '%s' "$TOKEN" > "$ZIEL/audi_dashboard/ha_token.txt" + +echo "==> Neu starten" +docker restart "$NAME" >/dev/null + +echo "==> Auf Backend warten" +for _ in $(seq 1 60); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ + -H "Authorization: Bearer $TOKEN" "$BASIS/api/" || true)" = "200" ]; then + break + fi + sleep 5 +done + +echo "==> Token nach companion-app/.env.local schreiben" +printf 'VITE_TEST_URL=%s\nVITE_TEST_TOKEN=%s\n' "$BASIS" "$TOKEN" \ + > "$PROJEKT/companion-app/.env.local" + +echo +echo "Fertig. $BASIS · Benutzer test · Passwort testtest123" +echo "Token liegt in companion-app/.env.local (gitignored)." diff --git a/testumgebung/einrichten.mjs b/testumgebung/einrichten.mjs new file mode 100644 index 0000000..506108d --- /dev/null +++ b/testumgebung/einrichten.mjs @@ -0,0 +1,102 @@ +// Legt in einer frischen Home-Assistant-Instanz einen Testnutzer an und gibt ein +// langlebiges Zugriffstoken auf der Standardausgabe aus. Meldungen gehen auf die +// Fehlerausgabe, damit das aufrufende Skript nur das Token einliest. +// Aufruf: node einrichten.mjs http://localhost:18123 + +const BASIS = process.argv[2] ?? "http://localhost:18123" +const CLIENT_ID = BASIS + "/" +const BENUTZER = "test" +const PASSWORT = "testtest123" + +const melden = (text) => process.stderr.write(` ${text}\n`) + +async function jsonPost(pfad, koerper, token) { + const kopf = { "Content-Type": "application/json" } + if (token) kopf.Authorization = `Bearer ${token}` + const antwort = await fetch(BASIS + pfad, { + method: "POST", + headers: kopf, + body: JSON.stringify(koerper), + }) + const text = await antwort.text() + if (!antwort.ok) throw new Error(`${pfad} -> ${antwort.status}: ${text.slice(0, 200)}`) + return text ? JSON.parse(text) : {} +} + +// 1. Onboarding-Schritt "user" +const { auth_code: authCode } = await jsonPost("/api/onboarding/users", { + client_id: CLIENT_ID, + name: "Test Nutzer", + username: BENUTZER, + password: PASSWORT, + language: "de", +}) +melden("Nutzer angelegt") + +// 2. Auth-Code gegen ein kurzlebiges Zugriffstoken tauschen +const tokenAntwort = await fetch(BASIS + "/auth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: authCode, + client_id: CLIENT_ID, + }).toString(), +}) +const token = await tokenAntwort.json() +if (!token.access_token) throw new Error("kein access_token: " + JSON.stringify(token)) +melden("Zugriffstoken erhalten") + +// 3. Restliche Onboarding-Schritte abschliessen (Fehler sind unkritisch) +for (const [pfad, koerper] of [ + ["/api/onboarding/core_config", {}], + ["/api/onboarding/analytics", {}], + ["/api/onboarding/integration", { client_id: CLIENT_ID, redirect_uri: CLIENT_ID }], +]) { + try { + await jsonPost(pfad, koerper, token.access_token) + } catch { + // Schritt bereits erledigt oder in dieser Version nicht vorhanden + } +} + +// 4. Langlebiges Token ueber die WebSocket-API erzeugen +const wsUrl = BASIS.replace(/^http/, "ws") + "/api/websocket" +const llat = await new Promise((aufloesen, ablehnen) => { + const ws = new WebSocket(wsUrl) + const zeitgeber = setTimeout(() => { + ablehnen(new Error("Zeitueberschreitung beim Erzeugen des Tokens")) + ws.close() + }, 20000) + ws.onmessage = (nachricht) => { + const daten = JSON.parse(nachricht.data) + if (daten.type === "auth_required") { + ws.send(JSON.stringify({ type: "auth", access_token: token.access_token })) + } else if (daten.type === "auth_ok") { + ws.send( + JSON.stringify({ + id: 1, + type: "auth/long_lived_access_token", + client_name: `DataMetric360 Test ${Date.now()}`, + lifespan: 365, + }), + ) + } else if (daten.type === "result") { + clearTimeout(zeitgeber) + if (daten.success) aufloesen(daten.result) + else ablehnen(new Error(JSON.stringify(daten.error))) + ws.close() + } else if (daten.type === "auth_invalid") { + clearTimeout(zeitgeber) + ablehnen(new Error("auth_invalid")) + ws.close() + } + } + ws.onerror = () => { + clearTimeout(zeitgeber) + ablehnen(new Error("WebSocket-Verbindung fehlgeschlagen")) + } +}) +melden("langlebiges Token erzeugt") + +process.stdout.write(llat) diff --git a/testumgebung/konfiguration.yaml b/testumgebung/konfiguration.yaml new file mode 100644 index 0000000..159a4fd --- /dev/null +++ b/testumgebung/konfiguration.yaml @@ -0,0 +1,159 @@ +# Wegwerf-Testinstanz fuer DataMetric360. +# Bildet die Entitaeten der HACS-Integration TommiG1/HA_VAG-EU-Data-Act nach, +# damit das pyscript-Backend echte Werte sieht. Ueber die input_*-Helfer sind +# die Werte zur Laufzeit aenderbar (Fahrterkennung, Reifenzaehler, Tankerkennung +# haengen an @state_trigger). + +default_config: + +pyscript: + allow_all_imports: true + +panel_custom: + - name: audi-dashboard-panel + sidebar_title: Mein Audi + sidebar_icon: mdi:car-sports + module_url: /local/audi-dashboard-panel.js?v=1 + embed_iframe: false + trust_external_script: false + +input_number: + test_kilometerstand: + name: Test Kilometerstand + min: 0 + max: 999999 + step: 1 + initial: 48250 + mode: box + test_tankfuellstand: + name: Test Tankfuellstand + min: 0 + max: 100 + step: 1 + initial: 62 + mode: box + test_reichweite: + name: Test Reichweite + min: 0 + max: 1200 + step: 1 + initial: 385 + mode: box + +input_text: + test_wlan: + name: Test WLAN + initial: Zuhause + max: 60 + +input_boolean: + test_tuer_vl: + name: Test Tuer vorne links + test_fenster_vl: + name: Test Fenster vorne links + test_entriegelt: + name: Test entriegelt + +template: + - sensor: + - name: "iPhone WiFi Connection" + unique_id: test_iphone_wifi + state: "{{ states('input_text.test_wlan') }}" + - name: "Audi RS 4 Avant Mileage" + unique_id: test_audi_mileage + state: "{{ states('input_number.test_kilometerstand') | int(0) }}" + unit_of_measurement: km + state_class: total_increasing + - name: "Audi RS 4 Avant Fuel Level" + unique_id: test_audi_fuel + state: "{{ states('input_number.test_tankfuellstand') | int(0) }}" + unit_of_measurement: "%" + - name: "Audi RS 4 Avant Range Primary" + unique_id: test_audi_range + state: "{{ states('input_number.test_reichweite') | int(0) }}" + unit_of_measurement: km + - name: "Audi RS 4 Avant Oil Change Due" + unique_id: test_audi_oil_due + state: "2027-03-14T00:00:00+00:00" + device_class: timestamp + - name: "Audi RS 4 Avant Oil Change Distance" + unique_id: test_audi_oil_dist + state: "-11750" + unit_of_measurement: km + - name: "Audi RS 4 Avant Inspection Due" + unique_id: test_audi_insp_due + state: "2027-03-14T00:00:00+00:00" + device_class: timestamp + - name: "Audi RS 4 Avant Inspection Distance" + unique_id: test_audi_insp_dist + state: "-11750" + unit_of_measurement: km + - binary_sensor: + - name: "Audi RS 4 Avant Front Left Door" + unique_id: test_audi_door_fl + state: "{{ is_state('input_boolean.test_tuer_vl', 'on') }}" + device_class: door + - name: "Audi RS 4 Avant Front Right Door" + unique_id: test_audi_door_fr + state: "off" + device_class: door + - name: "Audi RS 4 Avant Rear Left Door" + unique_id: test_audi_door_rl + state: "off" + device_class: door + - name: "Audi RS 4 Avant Rear Right Door" + unique_id: test_audi_door_rr + state: "off" + device_class: door + - name: "Audi RS 4 Avant Front Left Window" + unique_id: test_audi_win_fl + state: "{{ is_state('input_boolean.test_fenster_vl', 'on') }}" + device_class: window + - name: "Audi RS 4 Avant Front Right Window" + unique_id: test_audi_win_fr + state: "off" + device_class: window + - name: "Audi RS 4 Avant Rear Left Window" + unique_id: test_audi_win_rl + state: "off" + device_class: window + - name: "Audi RS 4 Avant Rear Right Window" + unique_id: test_audi_win_rr + state: "off" + device_class: window + - name: "Audi RS 4 Avant Front Left Door Lock" + unique_id: test_audi_lock_fl + state: "{{ is_state('input_boolean.test_entriegelt', 'on') }}" + device_class: lock + - name: "Audi RS 4 Avant Front Right Door Lock" + unique_id: test_audi_lock_fr + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Rear Left Door Lock" + unique_id: test_audi_lock_rl + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Rear Right Door Lock" + unique_id: test_audi_lock_rr + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Tailgate" + unique_id: test_audi_tailgate + state: "off" + device_class: door + - name: "Audi RS 4 Avant Tailgate Lock" + unique_id: test_audi_tailgate_lock + state: "off" + device_class: lock + - name: "Audi RS 4 Avant Hood" + unique_id: test_audi_hood + state: "off" + device_class: door + - name: "Audi RS 4 Avant Hood Lock" + unique_id: test_audi_hood_lock + state: "off" + device_class: lock + +input_button: + test_refresh: + name: Test Refresh Now From 8e268f044d718b2126af5563176d7aa6ed7bc638 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:48:33 +0200 Subject: [PATCH 03/18] Phase 2: Doku an Codestand angleichen, profil_lesen haerten Doku-Drift behoben: die Statistik-Seite rechnet laengst echt (Behauptung stand an drei Stellen), INSTALL.md nannte in Schritt 4 vier Variablennamen, die es nie gab, README erwaehnte die entfernte 97-Prozent-Volltankungsregel und liess fuenf pyscript-Dateien in der Uebersicht aus. Obsoleter TODO-Kommentar in belegverarbeitung.py entfernt. profil_lesen() gibt bei fehlender oder beschaedigter Profildatei None zurueck statt zu werfen; alle sieben Aufrufstellen fangen den Fall ab. An der Testinstanz geprueft: Datei entfernt, es folgt eine verstaendliche Fehlermeldung mit Verweis auf INSTALL.md statt eines Tracebacks pro Trigger-Durchlauf, Home Assistant laeuft normal weiter. --- homeassistant/INSTALL.md | 21 +++++++++++++------ homeassistant/README.md | 14 +++++++++---- homeassistant/pyscript/backup.py | 4 ++++ homeassistant/pyscript/belegverarbeitung.py | 2 +- homeassistant/pyscript/fahrterkennung.py | 4 ++++ .../modules/frontend_veroeffentlichung.py | 5 ++++- homeassistant/pyscript/modules/profil.py | 21 ++++++++++++++++++- homeassistant/pyscript/reifenzaehler.py | 2 ++ homeassistant/pyscript/tankerkennung.py | 5 ++++- homeassistant/www/audi-dashboard-app.js | 2 -- 10 files changed, 64 insertions(+), 16 deletions(-) diff --git a/homeassistant/INSTALL.md b/homeassistant/INSTALL.md index 419fc1e..b1a77ea 100644 --- a/homeassistant/INSTALL.md +++ b/homeassistant/INSTALL.md @@ -103,10 +103,18 @@ zeigt die Oberfläche „unbekannt" statt eines Werts — kein Absturz): 3. Ebenfalls in Entwicklerwerkzeuge → Zustände suchen: - Reichweite → `RANGE_SENSOR` - - Türstatus (falls als ein Sammel-Sensor vorhanden) → `DOORS_SENSOR` - - Fensterstatus → `WINDOWS_SENSOR` - - Verriegelung (Domäne `lock.`) → `LOCK_ENTITY` - - Batteriespannung, falls überhaupt vorhanden → `BATTERY_VOLTAGE_SENSOR` + - Türstatus → `TUER_SENSOREN` (Liste mit vier Einzelsensoren: + vorne links/rechts, hinten links/rechts) + - Fensterstatus → `FENSTER_SENSOREN` (ebenfalls vier Einzelsensoren) + - Verriegelung → `TUERSCHLOSS_SENSOREN` (vier Einzelsensoren; die + Integration liefert kein `lock.`-Entity) + - Heckklappe und Motorhaube → `HECKKLAPPE_SENSOR`, + `HECKKLAPPENSCHLOSS_SENSOR`, `HAUBE_SENSOR`, `HAUBENSCHLOSS_SENSOR` + - Service-Fälligkeit → `NAECHSTER_OELWECHSEL_SENSOR`, + `OELWECHSEL_STRECKE_SENSOR`, `NAECHSTE_INSPEKTION_SENSOR`, + `INSPEKTION_STRECKE_SENSOR` + - Batteriespannung, falls überhaupt vorhanden → `BATTERIE_SENSOR` + (bei der aktuellen Integration nicht vorhanden, bleibt leer) 4. Datei `pyscript/modules/einstellungen.py` öffnen (Samba: direkt im Explorer, Studio Code Server: im Editor) und alle gefundenen Werte @@ -201,8 +209,9 @@ Der `panel_custom`-Eintrag aus Schritt 3 zeigt auf Prototyp. Das Gerät braucht dafür zusätzlich zur Tailscale-Verbindung normalen Internetzugang — sonst bleibt die Karte auf der Fahrt- und Tankvorgang-Detailseite leer. -- **Statistik-Seite** zeigt weiterhin Beispielzahlen aus dem Prototyp, - keine echte Auswertung der eigenen Fahrten — eigenes Arbeitspaket. +- **Statistik-Seite** wertet die eigenen Fahrten und Tankvorgänge echt aus + (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg). Sie bleibt nur so + lange leer, wie noch keine Fahrten und Tankungen erfasst sind. ## Schritt 10 — künftige Updates einspielen, ohne die App neu zu bauen diff --git a/homeassistant/README.md b/homeassistant/README.md index 04c3a86..de7f604 100644 --- a/homeassistant/README.md +++ b/homeassistant/README.md @@ -96,8 +96,6 @@ Datendateien — nie App-Code oder -Konfiguration. `fahrterkennung.py`. - **Bilder fehlen** (§7a) — Ordner `www/bilder/` ist noch leer. Layout springt nicht (Maße sind reserviert), aber die Flächen bleiben leer. -- **Statistik-Seite** zeigt weiterhin Beispielzahlen aus dem Prototyp, keine - echte Auswertung der erfassten Fahrten. - **Leaflet lädt per CDN**, wie im Prototyp — braucht zusätzlich zu Tailscale echten Internetzugang auf dem Gerät. @@ -107,11 +105,14 @@ Datendateien — nie App-Code oder -Konfiguration. - Zweistufiger Fahrtabschluss per Recorder-Historie-Screening, inklusive Verkettung direkt anschließender Fahrten - Reifenzähler per direkter Subtraktion, kein `utility_meter` -- Belegverarbeitung inklusive 97-%-Volltankungsregel und Duplikatserkennung - über `receipt_key` +- Belegverarbeitung mit Duplikatserkennung über `receipt_key` (die zunächst + geplante 97-%-Volltankungsregel wurde per Änderungswunsch entfernt, siehe + Kopfkommentar in `pyscript/belegverarbeitung.py`) - Manuelle Fallback-Wege für Fahrten und Tankvorgänge ohne Beleg - Frontend: Übersicht, Fahrtenliste, Navigation zwischen allen Ansichten, Datenadapter Profil→Oberfläche, Schreibaktionen über `hass.callService` +- Statistik-Seite mit echter Auswertung aus Fahrten und Tankvorgängen + (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg) ## Dateiübersicht @@ -133,8 +134,13 @@ homeassistant/ │ │ └── frontend_veroeffentlichung.py Zustände fürs Frontend (§10 Punkt 7 Ersatz) │ ├── fahrterkennung.py §7.1 │ ├── fahrtabschluss.py §7.2 (Trigger-Registrierung) +│ ├── tankerkennung.py §7.4 (automatische Erkennung am Füllstandsanstieg) │ ├── reifenzaehler.py §7.5 │ ├── belegverarbeitung.py §7.4, §7.7 +│ ├── batterieverlauf.py 12-V-Spannung, Tagesminimum/-maximum +│ ├── bilderverwaltung.py Upload/Löschen der Fahrzeugfotos +│ ├── backup.py tägliche Sicherung und Wiederherstellung +│ ├── updateverwaltung.py Update suchen und installieren │ └── frontend_api.py Lese-/Schreib-Anbindung fürs Frontend ├── www/ │ ├── audi-dashboard-panel.js Lade-Stub (zeigt configuration.yaml hierher), ändert sich kaum diff --git a/homeassistant/pyscript/backup.py b/homeassistant/pyscript/backup.py index 54b658e..e92a70e 100644 --- a/homeassistant/pyscript/backup.py +++ b/homeassistant/pyscript/backup.py @@ -44,6 +44,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() @@ -60,6 +62,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 = ( diff --git a/homeassistant/pyscript/belegverarbeitung.py b/homeassistant/pyscript/belegverarbeitung.py index 81b32dc..0abb859 100644 --- a/homeassistant/pyscript/belegverarbeitung.py +++ b/homeassistant/pyscript/belegverarbeitung.py @@ -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 diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py index 78f7928..26f9973 100644 --- a/homeassistant/pyscript/fahrterkennung.py +++ b/homeassistant/pyscript/fahrterkennung.py @@ -34,11 +34,15 @@ _fahrt_start_ts = None def _profil_wlan_name(): p = profil.profil_lesen() + if p is None: + return None return p.get("fahrzeug", {}).get("wlan_name") def _pausenzeit_sekunden(): p = profil.profil_lesen() + if p is None: + return 15 * 60 return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py index 521d9d3..ffcd10e 100644 --- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py +++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py @@ -24,7 +24,10 @@ import profil def profil_veroeffentlichen(): - state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()}) + daten = profil.profil_lesen() + if daten is None: + return + state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten}) def fahrten_veroeffentlichen(): diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py index 62d7fe7..4631d86 100644 --- a/homeassistant/pyscript/modules/profil.py +++ b/homeassistant/pyscript/modules/profil.py @@ -49,10 +49,29 @@ def ordner_sicherstellen(): # ----------------------------------------------------------- Fahrzeugprofil def profil_lesen(): + """Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist. + + Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig, + siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das + Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder + Aufrufer muss den None-Fall abfangen.""" + if not os.path.exists(PROFIL_PFAD): + log.error( + f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — " + "bis dahin bleiben alle Funktionen aus, die das Profil brauchen." + ) + return None f = task.executor(io.open, PROFIL_PFAD, "r") inhalt = f.read() f.close() - return json.loads(inhalt) + try: + return json.loads(inhalt) + except ValueError as fehler: + log.error( + f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). " + "Letztes Backup aus audi_dashboard/backups/ zurückspielen." + ) + return None def profil_schreiben(profil): diff --git a/homeassistant/pyscript/reifenzaehler.py b/homeassistant/pyscript/reifenzaehler.py index 95c62f6..6f850c7 100644 --- a/homeassistant/pyscript/reifenzaehler.py +++ b/homeassistant/pyscript/reifenzaehler.py @@ -60,6 +60,8 @@ def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None): Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden.""" p = profil.profil_lesen() + if p is None: + return reifen = p.setdefault("reifen", {}) saetze = reifen.setdefault("saetze", {}) aktiv = reifen.get("aktiv", "sommer") diff --git a/homeassistant/pyscript/tankerkennung.py b/homeassistant/pyscript/tankerkennung.py index 23c9889..f74fd68 100644 --- a/homeassistant/pyscript/tankerkennung.py +++ b/homeassistant/pyscript/tankerkennung.py @@ -57,7 +57,10 @@ def _als_zahl(wert): def _schwelle_prozent(): p = profil.profil_lesen() - tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER + if p is None: + tankvolumen = STANDARD_TANKVOLUMEN_LITER + else: + tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100 return min(liter_als_prozent, PROZENT_SCHWELLE) diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js index ec44b1e..08a818c 100644 --- a/homeassistant/www/audi-dashboard-app.js +++ b/homeassistant/www/audi-dashboard-app.js @@ -11,8 +11,6 @@ DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten). Bekannte Lücken in diesem Stand: - - Statistik-Seite (vStat/ST) zeigt weiterhin Beispielzahlen, keine echte - Auswertung aus TRIPS/FILLS - eigenes Arbeitspaket. - Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als gebrochene Bilder, bis bilder/ tatsächlich befüllt ist. From 2fe3576682beb27b4122974cde77287eb36adcc9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:50:51 +0200 Subject: [PATCH 04/18] Phase 4: Vite-React-Geruest und Workspace-Anbindung npm-Workspace im Repo-Root verbindet companion-app mit @audi-dash/ui, so dass die App die Komponentenbibliothek als echte Abhaengigkeit nutzt statt nur in der Doku. Dazu Vite, React 18, Einstiegsdateien und eine schlanke Grundlage-CSS, die nur enthaelt was eine App braucht und eine Bibliothek nicht mitbringt: Vollbild, sichere Bereiche, Scrollverhalten. Zwei Audit-Befunde des alten Panels sind hier von Anfang an beruecksichtigt: Formularfelder mit 16px (sonst zoomt iOS Safari beim Fokus) und ein globaler :focus-visible-Ring. Nebenbei im design-system den Kontrastwert --fg3 nachgezogen: dort stand noch #657081 mit 3,0:1 auf --tile, das Panel hatte den Wert laengst auf #8a94a3 korrigiert. Ohne das haette die neue App den bereits behobenen WCAG-Verstoss geerbt. --- companion-app/index.html | 17 + companion-app/package.json | 23 +- companion-app/src/App.tsx | 19 + companion-app/src/main.tsx | 15 + companion-app/src/stile/grundlage.css | 78 + companion-app/tsconfig.json | 4 +- companion-app/vite.config.ts | 16 + design-system/src/tokens/tokens.css | 5 +- package-lock.json | 4237 +++++++++++++++++++++++++ package.json | 17 + 10 files changed, 4426 insertions(+), 5 deletions(-) create mode 100644 companion-app/index.html create mode 100644 companion-app/src/App.tsx create mode 100644 companion-app/src/main.tsx create mode 100644 companion-app/src/stile/grundlage.css create mode 100644 companion-app/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json diff --git a/companion-app/index.html b/companion-app/index.html new file mode 100644 index 0000000..1599a53 --- /dev/null +++ b/companion-app/index.html @@ -0,0 +1,17 @@ + + + + + + + + DataMetric360 + + +
+ + + diff --git a/companion-app/package.json b/companion-app/package.json index f5337e3..2c9cba5 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -2,14 +2,31 @@ "name": "datametric360", "version": "0.1.0", "private": true, - "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.", + "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Datenschicht plus Oberfläche auf Basis von @audi-dash/ui.", "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "typecheck": "tsc --noEmit", - "smoke": "node --experimental-strip-types scripts/smoke.ts" + "smoke": "node --experimental-strip-types scripts/smoke.ts", + "smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts", + "test": "vitest run" + }, + "dependencies": { + "@audi-dash/ui": "*", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { + "@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", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vitest": "^2.1.8" } } diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx new file mode 100644 index 0000000..0c16a74 --- /dev/null +++ b/companion-app/src/App.tsx @@ -0,0 +1,19 @@ +import { Tile } from "@audi-dash/ui" + +/** + * Vorläufiger Einstieg — wird in Phase 5 durch die App-Shell ersetzt. + * Das äußerste Element trägt immer `ads-root` und `data-theme`, sonst greifen + * die Design-Tokens nicht (siehe design-system/.design-sync/conventions.md). + */ +export function App() { + return ( +
+
+ DataMetric360 + +
Gerüst steht
+
+
+
+ ) +} diff --git a/companion-app/src/main.tsx b/companion-app/src/main.tsx new file mode 100644 index 0000000..56af6d4 --- /dev/null +++ b/companion-app/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" + +import "@audi-dash/ui/styles.css" +import "./stile/grundlage.css" +import { App } from "./App" + +const wurzel = document.getElementById("wurzel") +if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html") + +createRoot(wurzel).render( + + + , +) diff --git a/companion-app/src/stile/grundlage.css b/companion-app/src/stile/grundlage.css new file mode 100644 index 0000000..4e93324 --- /dev/null +++ b/companion-app/src/stile/grundlage.css @@ -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; + } +} diff --git a/companion-app/tsconfig.json b/companion-app/tsconfig.json index a252206..67eec57 100644 --- a/companion-app/tsconfig.json +++ b/companion-app/tsconfig.json @@ -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"] } diff --git a/companion-app/vite.config.ts b/companion-app/vite.config.ts new file mode 100644 index 0000000..eef373b --- /dev/null +++ b/companion-app/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, + }, +}) diff --git a/design-system/src/tokens/tokens.css b/design-system/src/tokens/tokens.css index 00fb91e..653a144 100644 --- a/design-system/src/tokens/tokens.css +++ b/design-system/src/tokens/tokens.css @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..60be17d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4237 @@ +{ + "name": "audi-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "audi-app", + "version": "0.1.0", + "workspaces": [ + "design-system", + "companion-app" + ] + }, + "companion-app": { + "name": "datametric360", + "version": "0.1.0", + "dependencies": { + "@audi-dash/ui": "*", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/leaflet": "^1.9.12", + "@types/node": "^24.13.3", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vitest": "^2.1.8" + } + }, + "design-system": { + "name": "@audi-dash/ui", + "version": "0.1.0", + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.3.0", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@audi-dash/ui": { + "resolved": "design-system", + "link": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/datametric360": { + "resolved": "companion-app", + "link": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.404", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..53435cd --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "audi-app", + "version": "0.1.0", + "private": true, + "description": "Werkstatt für DataMetric360: Design-System, Companion-App und das Home-Assistant-Panel.", + "workspaces": [ + "design-system", + "companion-app" + ], + "scripts": { + "build:ds": "npm run build --workspace @audi-dash/ui", + "dev": "npm run dev --workspace datametric360", + "build": "npm run build:ds && npm run build --workspace datametric360", + "typecheck": "npm run typecheck --workspaces --if-present", + "test": "npm run test --workspace datametric360" + } +} From 5c0c2d18980e4e68cd73bb996c8073f63934e0b1 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 10:55:10 +0200 Subject: [PATCH 05/18] Phase 5: App-Shell mit responsivem Layout, Navigation und Theme Zwei Layouts aus derselben Zusammensetzung: unter 1000px Tab-Leiste unten, darueber Seitenleiste links mit Brotkrume statt Zurueck-Pfeil. Navigation ist wie im alten Panel ein flacher Zustand mit fester ZURUECK-Tabelle statt eines Router-Pakets - die Verschachtelung ist hoechstens dreistufig. Theme und Tab-Beschriftung ueberleben einen Neustart (localStorage, abgesichert fuer den Privatmodus), Startwert folgt der Systemeinstellung. Symbole aus dem alten Panel uebernommen, damit App und Panel gleich aussehen. Neun Rendertests belegen die Abnahmekriterien: Layoutwechsel in beide Richtungen, Zurueck-Ziel ueber die Tabelle, Zahnrad statt Tab-Leiste fuer die Einstellungen, Theme-Persistenz. --- companion-app/package.json | 2 + companion-app/src/App.tsx | 38 +- companion-app/src/Shell.test.tsx | 73 ++ companion-app/src/Shell.tsx | 144 ++++ .../src/hooks/useBreiterBildschirm.ts | 26 + companion-app/src/navigation.ts | 112 +++ companion-app/src/screens/Platzhalter.tsx | 50 ++ companion-app/src/stile/rahmen.css | 186 +++++ companion-app/src/symbole.tsx | 163 +++++ companion-app/src/tests/aufbau.ts | 31 + companion-app/src/theme.test.ts | 23 + companion-app/src/theme.ts | 81 +++ companion-app/vitest.config.ts | 12 + package-lock.json | 687 +++++++++++++++++- 14 files changed, 1616 insertions(+), 12 deletions(-) create mode 100644 companion-app/src/Shell.test.tsx create mode 100644 companion-app/src/Shell.tsx create mode 100644 companion-app/src/hooks/useBreiterBildschirm.ts create mode 100644 companion-app/src/navigation.ts create mode 100644 companion-app/src/screens/Platzhalter.tsx create mode 100644 companion-app/src/stile/rahmen.css create mode 100644 companion-app/src/symbole.tsx create mode 100644 companion-app/src/tests/aufbau.ts create mode 100644 companion-app/src/theme.test.ts create mode 100644 companion-app/src/theme.ts create mode 100644 companion-app/vitest.config.ts diff --git a/companion-app/package.json b/companion-app/package.json index 2c9cba5..44f9f1d 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -20,11 +20,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.12", "@types/node": "^24.13.3", "@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" diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index 0c16a74..de95661 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -1,19 +1,35 @@ -import { Tile } from "@audi-dash/ui" +import { useCallback, useState } from "react" + +import { Shell } from "./Shell" +import type { Route, SeitenName } from "./navigation" +import { useTabBeschriftung, useTheme } from "./theme" +import { Platzhalter } from "./screens/Platzhalter" + +import "./stile/rahmen.css" /** - * Vorläufiger Einstieg — wird in Phase 5 durch die App-Shell ersetzt. - * Das äußerste Element trägt immer `ads-root` und `data-theme`, sonst greifen - * die Design-Tokens nicht (siehe design-system/.design-sync/conventions.md). + * Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere + * hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` — + * ohne diese Wurzelklasse greifen die Design-Tokens nicht + * (design-system/.design-sync/conventions.md). */ export function App() { + const [route, setzeRoute] = useState({ name: "home" }) + const [theme] = useTheme() + const [tabBeschriftung] = useTabBeschriftung() + + const geheZu = useCallback((name: SeitenName, id?: string) => { + setzeRoute(id === undefined ? { name } : { name, id }) + // Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die + // Scrollposition der alten. + document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 }) + }, []) + return ( -
-
- DataMetric360 - -
Gerüst steht
-
-
+
+ + +
) } diff --git a/companion-app/src/Shell.test.tsx b/companion-app/src/Shell.test.tsx new file mode 100644 index 0000000..661f124 --- /dev/null +++ b/companion-app/src/Shell.test.tsx @@ -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[0], geheZu = vi.fn()) { + render( + +

Inhalt

+
, + ) + return geheZu +} + +describe("Shell", () => { + beforeEach(() => { + bildschirmbreiteSetzen(390) + }) + + it("zeigt auf schmalen Bildschirmen die Tab-Leiste unten", () => { + const { container } = render( + +

Inhalt

+
, + ) + 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( + +

Inhalt

+
, + ) + 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") + }) +}) diff --git a/companion-app/src/Shell.tsx b/companion-app/src/Shell.tsx new file mode 100644 index 0000000..886c081 --- /dev/null +++ b/companion-app/src/Shell.tsx @@ -0,0 +1,144 @@ +/** + * 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 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: }, + { key: "audi", label: "Mein Audi", icon: }, + { key: "trips", label: "Fahrten", icon: }, + { key: "stat", label: "Statistik", icon: }, + { key: "fuel", label: "Tanken", icon: }, +] + +export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: ShellProps) { + const breit = useBreiterBildschirm() + const aktiverBereich = bereichVon(route.name) + const zurueckZiel = ZURUECK[route.name] + + const kopf = ( +
+ {zurueckZiel && !breit ? ( + + ) : null} +
+ DataMetric360 +
{TITEL[route.name]}
+
+ +
+ ) + + if (breit) { + return ( + <> + +
+ {hinweis} + {!istHauptbereich(route.name) && zurueckZiel ? ( +
+ +
+ ) : null} +
{children}
+
+ + ) + } + + return ( +
+ {hinweis} + {kopf} +
{children}
+ geheZu(schluessel as Hauptbereich)} + iconOnly={!tabBeschriftung} + className="dm-tableiste" + /> +
+ ) +} diff --git a/companion-app/src/hooks/useBreiterBildschirm.ts b/companion-app/src/hooks/useBreiterBildschirm.ts new file mode 100644 index 0000000..271974a --- /dev/null +++ b/companion-app/src/hooks/useBreiterBildschirm.ts @@ -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(() => { + 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 +} diff --git a/companion-app/src/navigation.ts b/companion-app/src/navigation.ts new file mode 100644 index 0000000..5be5078 --- /dev/null +++ b/companion-app/src/navigation.ts @@ -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> = { + 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 = { + 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" +} diff --git a/companion-app/src/screens/Platzhalter.tsx b/companion-app/src/screens/Platzhalter.tsx new file mode 100644 index 0000000..9329746 --- /dev/null +++ b/companion-app/src/screens/Platzhalter.tsx @@ -0,0 +1,50 @@ +/** + * Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite + * für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die + * Navigation, damit sich der Rahmen bedienen lässt. + */ + +import { Tile } from "@audi-dash/ui" + +import type { Route, SeitenName } from "../navigation" +import { TITEL } from "../navigation" + +const UNTERSEITEN: Partial> = { + home: ["sicherheit", "einst"], + audi: ["ident", "battverlauf", "service", "reifen", "vers"], + service: ["werkstatt", "sbuch"], + vers: ["beitrag", "vertragsdetails", "steuer", "notruf"], + vertragsdetails: ["schutz"], + trips: ["trip"], + fuel: ["fill"], +} + +export function Platzhalter({ + route, + geheZu, +}: { + route: Route + geheZu: (name: SeitenName, id?: string) => void +}) { + const weiter = UNTERSEITEN[route.name] ?? [] + return ( + <> + +
{TITEL[route.name]}
+

+ Diese Seite wird noch gebaut. +

+
+ {weiter.length > 0 && ( +
+ {weiter.map((ziel) => ( + geheZu(ziel)}> + weiter +
{TITEL[ziel]}
+
+ ))} +
+ )} + + ) +} diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css new file mode 100644 index 0000000..1e620d7 --- /dev/null +++ b/companion-app/src/stile/rahmen.css @@ -0,0 +1,186 @@ +/* + * Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche. + * Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui. + */ + +.dm-spalte { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +/* ------------------------------------------------------------- Kopfzeile */ + +.dm-kopf { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3); + border-bottom: 1px solid var(--line); + background: var(--canvas); +} + +.dm-kopf__titel { + flex: 1; + min-width: 0; +} + +.dm-kopf__zurueck, +.dm-kopf__aktion { + flex: none; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--fg2); + cursor: pointer; +} + +.dm-kopf__zurueck:hover, +.dm-kopf__aktion:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-kopf__aktion[aria-current="page"] { + color: var(--red); +} + +/* ---------------------------------------------------------- Inhaltsfläche */ + +.dm-inhalt { + flex: 1; + padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5)); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.dm-inhalt--breit { + padding: var(--sp-5) 32px 32px; +} + +/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt + untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */ +.dm-raster { + display: grid; + gap: var(--sp-3); + grid-template-columns: 1fr; +} + +@media (min-width: 1000px) { + .dm-raster { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + } + + .dm-raster--drei { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dm-raster__breit { + grid-column: 1 / -1; + } +} + +/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */ +.dm-zweispalter { + display: grid; + gap: var(--sp-4); + grid-template-columns: 1fr; + align-items: start; +} + +@media (min-width: 1000px) { + .dm-zweispalter { + grid-template-columns: minmax(0, 4fr) minmax(0, 5fr); + } +} + +/* ----------------------------------------------------------- Seitenleiste */ + +.dm-seitenleiste { + flex: 0 0 240px; + width: 240px; + height: 100%; + box-sizing: border-box; + border-right: 1px solid var(--line); + padding: 24px 14px 16px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--canvas); +} + +.dm-seitenleiste__marke { + padding: 0 10px var(--sp-4); +} + +.dm-seitenleiste__punkt { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 11px 10px; + border: 0; + border-radius: var(--r-func); + background: transparent; + color: var(--fg2); + font-size: 14px; + font-weight: 300; + text-align: left; + cursor: pointer; +} + +.dm-seitenleiste__punkt:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-seitenleiste__punkt--an { + background: var(--tile); + color: var(--fg); +} + +.dm-seitenleiste__fuss { + margin-top: auto; + padding-top: var(--sp-3); + border-top: 1px solid var(--line); +} + +/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als + Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */ +.dm-brotkrume { + padding: var(--sp-4) 32px 0; +} + +.dm-brotkrume button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--fg2); + font-size: 13px; + font-weight: 300; + cursor: pointer; + padding: 6px 0; +} + +.dm-brotkrume button:hover { + color: var(--fg); +} + +/* ------------------------------------------------------------ Tab-Leiste */ + +.dm-tableiste { + flex: none; + padding-bottom: env(safe-area-inset-bottom, 0px); + border-top: 1px solid var(--line); + background: var(--canvas); +} diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx new file mode 100644 index 0000000..c4d9263 --- /dev/null +++ b/companion-app/src/symbole.tsx @@ -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 ( + + ) +} + +export function SymbolUebersicht({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolAudi({ groesse }: SymbolProps) { + return ( + } + /> + ) +} + +export function SymbolFahrten({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolStatistik({ groesse }: SymbolProps) { + return } /> +} + +export function SymbolTanken({ groesse }: SymbolProps) { + return ( + + + + + + + + } + /> + ) +} + +export function SymbolZurueck({ groesse }: SymbolProps) { + return } /> +} + +export function SymbolZahnrad({ groesse }: SymbolProps) { + return ( + + + + + } + /> + ) +} + +export function SymbolSonne({ groesse }: SymbolProps) { + return ( + + + + + } + /> + ) +} + +export function SymbolMond({ groesse }: SymbolProps) { + return ( + } + /> + ) +} + +export function SymbolMehr({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} + +export function SymbolWarnung({ groesse }: SymbolProps) { + return ( + + + + + + } + /> + ) +} diff --git a/companion-app/src/tests/aufbau.ts b/companion-app/src/tests/aufbau.ts new file mode 100644 index 0000000..7f0b4e2 --- /dev/null +++ b/companion-app/src/tests/aufbau.ts @@ -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 diff --git a/companion-app/src/theme.test.ts b/companion-app/src/theme.test.ts new file mode 100644 index 0000000..5263d9b --- /dev/null +++ b/companion-app/src/theme.test.ts @@ -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") + }) +}) diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts new file mode 100644 index 0000000..69cf26b --- /dev/null +++ b/companion-app/src/theme.ts @@ -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(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(() => { + 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] +} diff --git a/companion-app/vitest.config.ts b/companion-app/vitest.config.ts new file mode 100644 index 0000000..2fbdb68 --- /dev/null +++ b/companion-app/vitest.config.ts @@ -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"], + }, +}) diff --git a/package-lock.json b/package-lock.json index 60be17d..ad26fd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,11 +22,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.12", "@types/node": "^24.13.3", "@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" @@ -48,6 +50,57 @@ "react-dom": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@audi-dash/ui": { "resolved": "design-system", "link": true @@ -287,6 +340,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -335,6 +398,161 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -777,6 +995,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1201,6 +1437,62 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1294,7 +1586,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1444,6 +1735,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1451,6 +1765,16 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1474,6 +1798,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1633,6 +1967,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1640,6 +1988,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/datametric360": { "resolved": "companion-app", "link": true @@ -1662,6 +2024,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1672,6 +2041,23 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.404", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", @@ -1679,6 +2065,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1814,6 +2213,26 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1830,6 +2249,58 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1921,6 +2392,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1931,6 +2412,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2002,6 +2490,19 @@ "node": ">=0.10.0" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2135,6 +2636,31 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2153,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2161,6 +2688,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2185,6 +2719,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2241,6 +2785,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2324,6 +2881,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2408,6 +2972,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2500,6 +3110,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -4209,6 +4829,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4226,6 +4894,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", From 0103508ceacc22b77d833fa18cae23213ef3f8d4 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:02:44 +0200 Subject: [PATCH 06/18] Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene Reaktionen verlangen. Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage brachte: 1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt und sicherheit. Das Backend schreibt tankprozent, gesichert und sicherheitscheck - die Oberflaeche haette still ueberall undefined gelesen, ohne dass irgendetwas fehlgeschlagen waere. 2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen, den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu ueberschreiben. Abschnitte werden jetzt kopiert. Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests laufen. Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf, vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests. --- companion-app/scripts/smoke-auth.ts | 189 ++++++++++++++ companion-app/src/App.tsx | 78 ++++-- companion-app/src/api/rest.ts | 14 +- companion-app/src/api/types.ts | 16 +- companion-app/src/api/warteschlange.ts | 8 +- companion-app/src/daten/DatenKontext.tsx | 213 ++++++++++++++++ companion-app/src/daten/profilAdapter.test.ts | 158 ++++++++++++ companion-app/src/daten/profilAdapter.ts | 241 ++++++++++++++++++ companion-app/src/format.ts | 103 ++++++++ companion-app/src/screens/Einrichtung.tsx | 112 ++++++++ companion-app/src/screens/Hinweisleiste.tsx | 48 ++++ companion-app/src/screens/register.tsx | 36 +++ companion-app/src/stile/screens.css | 167 ++++++++++++ 13 files changed, 1358 insertions(+), 25 deletions(-) create mode 100644 companion-app/scripts/smoke-auth.ts create mode 100644 companion-app/src/daten/DatenKontext.tsx create mode 100644 companion-app/src/daten/profilAdapter.test.ts create mode 100644 companion-app/src/daten/profilAdapter.ts create mode 100644 companion-app/src/format.ts create mode 100644 companion-app/src/screens/Einrichtung.tsx create mode 100644 companion-app/src/screens/Hinweisleiste.tsx create mode 100644 companion-app/src/screens/register.tsx create mode 100644 companion-app/src/stile/screens.css diff --git a/companion-app/scripts/smoke-auth.ts b/companion-app/scripts/smoke-auth.ts new file mode 100644 index 0000000..f8b11fc --- /dev/null +++ b/companion-app/scripts/smoke-auth.ts @@ -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 { + try { + const inhalt = readFileSync(umgebungsdatei, "utf8") + const werte: Record = {} + 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() + 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>(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>(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(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((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 diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index de95661..082fe9a 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -1,35 +1,81 @@ -import { useCallback, useState } from "react" +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 { Platzhalter } from "./screens/Platzhalter" +import { Einrichtung } from "./screens/Einrichtung" +import { Hinweisleiste } from "./screens/Hinweisleiste" +import { SeiteFuer } from "./screens/register" import "./stile/rahmen.css" +import "./stile/screens.css" + +type Startzustand = "pruefen" | "einrichten" | "bereit" /** - * Wurzel der App. Hält den Navigationszustand und das Theme; alles Weitere - * hängt darunter. Das äußerste Element trägt `ads-root` und `data-theme` — - * ohne diese Wurzelklasse greifen die Design-Tokens nicht - * (design-system/.design-sync/conventions.md). + * 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 [route, setzeRoute] = useState({ name: "home" }) const [theme] = useTheme() - const [tabBeschriftung] = useTabBeschriftung() + const [start, setzeStart] = useState("pruefen") - const geheZu = useCallback((name: SeitenName, id?: string) => { - setzeRoute(id === undefined ? { name } : { name, id }) - // Beim Seitenwechsel immer oben anfangen — sonst erbt die neue Seite die - // Scrollposition der alten. - document.querySelector(".dm-inhalt")?.scrollTo({ top: 0 }) + // 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((zugang) => setzeStart(zugang ? "bereit" : "einrichten")) }, []) return (
- - - + {start === "pruefen" &&
Einen Moment …
} + {start === "einrichten" && setzeStart("bereit")} />} + {start === "bereit" && ( + + setzeStart("einrichten")} /> + + )}
) } + +function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) { + const [route, setzeRoute] = useState({ 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 ( + + } + > + + + ) +} diff --git a/companion-app/src/api/rest.ts b/companion-app/src/api/rest.ts index 2cdc2a5..d771468 100644 --- a/companion-app/src/api/rest.ts +++ b/companion-app/src/api/rest.ts @@ -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 diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 7db01b3..598cd47 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -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; } diff --git a/companion-app/src/api/warteschlange.ts b/companion-app/src/api/warteschlange.ts index f841a8b..a46ad5e 100644 --- a/companion-app/src/api/warteschlange.ts +++ b/companion-app/src/api/warteschlange.ts @@ -41,8 +41,14 @@ export class Warteschlange { #geladen = false; #laeuft = false; #horcher = new Set(); + /* 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 { if (this.#geladen) return; diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx new file mode 100644 index 0000000..1c890e1 --- /dev/null +++ b/companion-app/src/daten/DatenKontext.tsx @@ -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 + jetztAktualisieren: () => Promise + /** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */ + profilSpeichern: (aenderung: { + einstellungen?: Einstellungen + fahrzeug?: Fahrzeug + }) => Promise +} + +const Kontext = createContext(null) + +export function useDaten(): DatenWert { + const wert = useContext(Kontext) + if (!wert) throw new Error("useDaten außerhalb von verwendet") + return wert +} + +export function DatenAnbieter({ + api, + children, +}: { + api: DataMetricApi + children: ReactNode +}) { + const [rohprofil, setzeRohprofil] = useState(null) + const [status, setzeStatus] = useState({}) + const [statusStand, setzeStatusStand] = useState(null) + const [fahrten, setzeFahrten] = useState([]) + const [tankvorgaenge, setzeTankvorgaenge] = useState([]) + const [bereit, setzeBereit] = useState(false) + const [ladefehler, setzeLadefehler] = useState(null) + const [verbindung, setzeVerbindung] = useState("getrennt") + const [warteschlange, setzeWarteschlange] = useState([]) + + // Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch + // wenn zwischendurch ein Push hereinkam. + const rohprofilRef = useRef(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 {children} +} diff --git a/companion-app/src/daten/profilAdapter.test.ts b/companion-app/src/daten/profilAdapter.test.ts new file mode 100644 index 0000000..0a5095f --- /dev/null +++ b/companion-app/src/daten/profilAdapter.test.ts @@ -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 { + 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)["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)["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)["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)["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: { 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)["kennzeichen"]).toBe("M XY 9999") + // Abschnitte, die die Oberfläche nicht anfasst, bleiben unverändert. + expect(neu.technik).toEqual({ 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)["saetze"] as Record< + string, + Record + > + 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)["faellig"]).toBeNull() + }) +}) diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts new file mode 100644 index 0000000..75e0ddb --- /dev/null +++ b/companion-app/src/daten/profilAdapter.ts @@ -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 + steuer: Record & { faellig: string } + reifen: { + aktiv: "Sommer" | "Winter" + anzugsmoment: number | null + wechsel: Record + sommer: Reifensatz + winter: Reifensatz + } + service: Record + technik: Record + 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 { + const wert = profil[name] + if (!wert || typeof wert !== "object") return {} + return structuredClone(wert) as Record +} + +function text(quelle: Record, feld: string, ersatz = ""): string { + const wert = quelle[feld] + return typeof wert === "string" ? wert : ersatz +} + +function zahl(quelle: Record, 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 + const smartdeal = (einst["smartdeal"] ?? {}) as Record + const oel = (einst["oelwechsel_intervall"] ?? {}) as Record + 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, + 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 + const e = (p["einstellungen"] ??= {}) as Record + const r = (p["reifen"] ??= {}) as Record + + 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 + 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 +} diff --git a/companion-app/src/format.ts b/companion-app/src/format.ts new file mode 100644 index 0000000..b8914ec --- /dev/null +++ b/companion-app/src/format.ts @@ -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(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) +} diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx new file mode 100644 index 0000000..76cf81c --- /dev/null +++ b/companion-app/src/screens/Einrichtung.tsx @@ -0,0 +1,112 @@ +/** + * 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" + +export function Einrichtung({ fertig }: { fertig: () => void }) { + const [adresse, setzeAdresse] = useState("") + const [token, setzeToken] = useState("") + const [laeuft, setzeLaeuft] = useState(false) + const [fehler, setzeFehler] = useState(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) + 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 ( +
+
+ DataMetric360 +

Willkommen

+

+ 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. +

+ + + + setzeAdresse(e.target.value)} + /> + + + setzeToken(e.target.value)} + /> + + + + {fehler && ( +

+ {fehler} +

+ )} + +
+ void verbinden()} disabled={laeuft}> + {laeuft ? "Verbinde …" : "Verbinden"} + +
+ +

+ Den Token erzeugst du in Home Assistant unter „Profil → Sicherheit → Langlebige + Zugriffstoken". Deine Daten bleiben auf deinem Server. +

+
+
+ ) +} diff --git a/companion-app/src/screens/Hinweisleiste.tsx b/companion-app/src/screens/Hinweisleiste.tsx new file mode 100644 index 0000000..b3712ea --- /dev/null +++ b/companion-app/src/screens/Hinweisleiste.tsx @@ -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 ( +
+ + + {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} + +
+ ) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx new file mode 100644 index 0000000..b64e02e --- /dev/null +++ b/companion-app/src/screens/register.tsx @@ -0,0 +1,36 @@ +/** + * 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 { Platzhalter } from "./Platzhalter" + +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 ( +
+
Daten nicht erreichbar
+

{daten.ladefehler}

+
+ ) + } + return
Daten werden geladen …
+ } + + return +} diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css new file mode 100644 index 0000000..40db87f --- /dev/null +++ b/companion-app/src/stile/screens.css @@ -0,0 +1,167 @@ +/* + * 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; +} From 4dbf3cb173ae0643536c943dc7697f76524b6b86 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:07:03 +0200 Subject: [PATCH 07/18] Phase 7a: Uebersicht, Fahrzeugstatus und Mein Audi Erste drei Screens auf der fertigen Datenschicht. Uebersicht mit Fahrzeugbild, Sicherungsstatus, Reichweite und Tankfuellstand, Kilometerstand samt Service-Faelligkeit und Vorschau auf letzte Fahrt und Tankung. Fahrzeugstatus zeigt die 16 Einzelpruefungen, wobei ein unbekannter Sensor als hohler Ring erscheint statt als gruen geratener Punkt - dieselbe Vorsicht, die schon das Backend walten laesst. Mein Audi buendelt Fotogalerie und Wege zu den Unterseiten. Dazu die geteilte Rechenlogik der Statistik portiert (Zeitraeume, Langzeitverbrauch mit Plausibilitaetsfenster, Gruppierung nach Jahr und Monat) und Formatierung im deutschen Format. Bild-Komponente kapselt eine Reibung zwischen App und Bibliothek: die App laeuft mit exactOptionalPropertyTypes, ein undefined an einem optionalen Prop waere sonst an jeder Aufrufstelle einzeln zu behandeln. --- companion-app/src/App.tsx | 7 +- companion-app/src/daten/statistik.ts | 242 +++++++++++++++++++ companion-app/src/screens/Bild.tsx | 35 +++ companion-app/src/screens/Einrichtung.tsx | 2 + companion-app/src/screens/Fahrzeugstatus.tsx | 80 ++++++ companion-app/src/screens/MeinAudi.tsx | 72 ++++++ companion-app/src/screens/Uebersicht.tsx | 194 +++++++++++++++ companion-app/src/screens/bausteine.tsx | 96 ++++++++ companion-app/src/screens/bilder.ts | 49 ++++ companion-app/src/screens/register.tsx | 20 +- companion-app/src/screens/zugang.ts | 23 ++ companion-app/src/stile/screens.css | 187 ++++++++++++++ 12 files changed, 1003 insertions(+), 4 deletions(-) create mode 100644 companion-app/src/daten/statistik.ts create mode 100644 companion-app/src/screens/Bild.tsx create mode 100644 companion-app/src/screens/Fahrzeugstatus.tsx create mode 100644 companion-app/src/screens/MeinAudi.tsx create mode 100644 companion-app/src/screens/Uebersicht.tsx create mode 100644 companion-app/src/screens/bausteine.tsx create mode 100644 companion-app/src/screens/bilder.ts create mode 100644 companion-app/src/screens/zugang.ts diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index 082fe9a..5efa43b 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -8,6 +8,7 @@ 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" @@ -28,7 +29,11 @@ export function App() { const api = useMemo(() => new DataMetricApi(), []) useEffect(() => { - void zugangLesen().then((zugang) => setzeStart(zugang ? "bereit" : "einrichten")) + 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 ( diff --git a/companion-app/src/daten/statistik.ts b/companion-app/src/daten/statistik.ts new file mode 100644 index 0000000..579db90 --- /dev/null +++ b/companion-app/src/daten/statistik.ts @@ -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 = { + 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 + 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 { + /** Format YYYY-MM, damit sich absteigend sortieren lässt. */ + schluessel: string + jahr: number + monat: number + eintraege: T[] +} + +export interface JahresGruppe { + jahr: number + monate: MonatsGruppe[] + 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( + eintraege: readonly T[], + zeitpunktVon: (eintrag: T) => string, +): JahresGruppe[] { + const jahre = new Map>>() + + 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), + } + }) +} diff --git a/companion-app/src/screens/Bild.tsx b/companion-app/src/screens/Bild.tsx new file mode 100644 index 0000000..4b579ff --- /dev/null +++ b/companion-app/src/screens/Bild.tsx @@ -0,0 +1,35 @@ +/** + * Fahrzeugbild mit Platzhalter. + * + * Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit + * `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string` + * nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert + * es hier einmal. + */ + +import { ImagePlaceholder } from "@audi-dash/ui" + +import { bildUrl } from "./bilder" + +export function Bild({ + datei, + beschreibung, + name, + groesse, +}: { + datei: string | null | undefined + beschreibung: string + name: string + groesse?: "default" | "mini" | "round" +}) { + const url = bildUrl(datei) + return ( + + ) +} diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx index 76cf81c..2c40d07 100644 --- a/companion-app/src/screens/Einrichtung.tsx +++ b/companion-app/src/screens/Einrichtung.tsx @@ -12,6 +12,7 @@ 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("") @@ -37,6 +38,7 @@ export function Einrichtung({ fertig }: { fertig: () => void }) { // die nachweislich nicht funktionieren. await new HassRest(zugang).verbindungPruefen() await zugangSpeichern(zugang) + await zugangMerken() fertig() } catch (ursache) { if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) { diff --git a/companion-app/src/screens/Fahrzeugstatus.tsx b/companion-app/src/screens/Fahrzeugstatus.tsx new file mode 100644 index 0000000..d253895 --- /dev/null +++ b/companion-app/src/screens/Fahrzeugstatus.tsx @@ -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 ( + + ) + } + + const unbekannt = punkte.filter((p) => p.ok === null).length + const offen = punkte.filter((p) => p.ok === false) + + return ( + <> + + + + Einzelprüfungen +
    + {punkte.map((punkt) => ( +
  • +
  • + ))} +
+
+ + {unbekannt > 0 && ( +

+ {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. +

+ )} + + ) +} diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx new file mode 100644 index 0000000..63c4029 --- /dev/null +++ b/companion-app/src/screens/MeinAudi.tsx @@ -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 ( + <> + + Fotogalerie +
+ {BILDPLAETZE.map((platz) => ( + + ))} +
+

+ Fotos verwaltest du in den Einstellungen. Fehlende Bilder zeigen ihren erwarteten + Dateinamen, damit klar ist, was wohin gehört. +

+
+ +
+ geheZu("ident")} + /> + geheZu("battverlauf")} + /> + geheZu("service")} /> + geheZu("reifen")} + /> + geheZu("vers")} + /> +
+ + ) +} diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx new file mode 100644 index 0000000..d9734be --- /dev/null +++ b/companion-app/src/screens/Uebersicht.tsx @@ -0,0 +1,194 @@ +/** + * Ü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" + +export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { + const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } = + useDaten() + const [aktualisiert, setzeAktualisiert] = useState(false) + + if (!fahrzeug || !einstellungen) return null + + const letzteFahrt = fahrten + .slice() + .sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0] + const letzteTankung = tankvorgaenge + .slice() + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0] + + const sicherheitsstatus = + fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn" + const sicherheitstext = + fahrzeug.gesichert === true + ? "Sicher abgestellt" + : fahrzeug.gesichert === false + ? "Nicht vollständig verschlossen" + : "Zustand unbekannt" + + const aktualisieren = async () => { + setzeAktualisiert(true) + try { + await jetztAktualisieren() + } finally { + setzeAktualisiert(false) + } + } + + return ( + <> +
+ +
+
+ {einstellungen.fahrzeugtitel}{" "} + {einstellungen.fahrzeugzusatz && ( + {einstellungen.fahrzeugzusatz} + )} +
+ + {einstellungen.kennzeichen || "Kennzeichen nicht hinterlegt"} + +
+
+ + geheZu("sicherheit")} + /> + +
+ + Reichweite + + +
+ {de(fahrzeug.tankPct)} % im Tank + + rund {de((einstellungen.tankvolumen * fahrzeug.tankPct) / 100, 1)} von{" "} + {de(einstellungen.tankvolumen)} l + +
+
+ + + Kilometerstand + + + + + + } + /> + +
+ +
+ {letzteFahrt ? ( + +
+ {deOderStrich(letzteFahrt.distance_km, 1)} km +
+ + {datum(letzteFahrt.ts_start)} · {dauer(letzteFahrt.duration_s)} + + + } + beiKlick={() => geheZu("trip", letzteFahrt.trip_id)} + /> + ) : ( + + Letzte Fahrt + + + )} + + {letzteTankung ? ( + +
+ {deOderStrich(letzteTankung.liters, 2)} l +
+ + {datum(letzteTankung.ts)} ·{" "} + {letzteTankung.fuel_total_eur != null + ? `${eur(letzteTankung.fuel_total_eur)} €` + : "Betrag offen"} + + + } + beiKlick={() => geheZu("fill", letzteTankung.tank_id)} + /> + ) : ( + + Letzte Tankung + + + )} +
+ +
+ +
+ + ) +} diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx new file mode 100644 index 0000000..24f034b --- /dev/null +++ b/companion-app/src/screens/bausteine.tsx @@ -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 ( +
+
{titel}
+

{text}

+ {aktion && ( +
+ {aktion.beschriftung} +
+ )} +
+ ) +} + +/** Abschnittsüberschrift zwischen Kacheln. */ +export function Abschnitt({ titel, kinder }: { titel: string; kinder?: ReactNode }) { + return ( +
+ {titel} + {kinder} +
+ ) +} + +/** Kachel, die zu einer Unterseite führt. */ +export function NaviKachel({ + titel, + wert, + beiKlick, +}: { + titel: string + wert?: ReactNode + beiKlick: () => void +}) { + return ( + + {titel} + {wert !== undefined &&
{wert}
} +
+ ) +} + +/** + * 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 ( +
+
{label}
+
+ {wert} + {zusatz && {zusatz}} +
+
+ ) +} + +export function Werteliste({ kinder }: { kinder: ReactNode }) { + return
{kinder}
+} + +/** Bestätigung vor unwiderruflichen Aktionen. */ +export function bestaetigen(frage: string): boolean { + return window.confirm(frage) +} diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts new file mode 100644 index 0000000..02316bf --- /dev/null +++ b/companion-app/src/screens/bilder.ts @@ -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}` +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index b64e02e..4f71172 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -5,7 +5,10 @@ import { useDaten } from "../daten/DatenKontext" import type { Route, SeitenName } from "../navigation" +import { Fahrzeugstatus } from "./Fahrzeugstatus" +import { MeinAudi } from "./MeinAudi" import { Platzhalter } from "./Platzhalter" +import { Uebersicht } from "./Uebersicht" export interface SeitenProps { route: Route @@ -18,8 +21,8 @@ export interface SeitenProps { 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". + // 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 ( @@ -32,5 +35,16 @@ export function SeiteFuer(props: SeitenProps) { return
Daten werden geladen …
} - return + const { route, geheZu } = props + + switch (route.name) { + case "home": + return + case "sicherheit": + return + case "audi": + return + default: + return + } } diff --git a/companion-app/src/screens/zugang.ts b/companion-app/src/screens/zugang.ts new file mode 100644 index 0000000..011c96a --- /dev/null +++ b/companion-app/src/screens/zugang.ts @@ -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 { + gemerkt = await zugangLesen() +} + +export function zugangLesenSynchron(): Zugang | null { + return gemerkt +} + +export function zugangVergessen(): void { + gemerkt = null +} diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 40db87f..03d5cc7 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -165,3 +165,190 @@ 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); +} From 8b64a808ed9309edeeaebd4b3b7aa530dcd0503e Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:11:00 +0200 Subject: [PATCH 08/18] Phase 7b: Fahrten, Tanken und Statistik Listen mit Jahr-Monat-Aufklappung, Detailseiten, manuelles Eintragen und Beleg-Upload. Statistik rechnet ueber die portierten Regeln. Zwei offene Audit-Befunde des alten Panels sind hier gleich mit erledigt: 1. Loeschen war ausschliesslich per Wischgeste erreichbar. Wer mit Sprachausgabe oder Schaltersteuerung bedient, wischt nicht - Fahrten und Tankvorgaenge waren fuer diese Nutzer gar nicht loeschbar. Jede Zeile hat jetzt zusaetzlich ein sichtbares Menue, ein richtiger Knopf mit Tastatur. 2. Leaflet kam per CDN und blieb auf einem Handy ohne eigenen Internetzugang still leer. Es liegt jetzt im Buendel (eigener Abschnitt, erst bei Bedarf geladen). Bewusst NICHT uebernommen: fakeTrack() aus dem alten Panel, das eine erfundene Linie zwischen zwei Punkten zeichnete, wenn keine Route vorlag. Solange das Fahrzeug keine Positionen liefert, sagt die Seite das - statt eine glaubwuerdig aussehende Erfindung zu zeigen. --- companion-app/src/api/index.ts | 44 ++++ companion-app/src/screens/FahrtDetail.tsx | 111 ++++++++++ companion-app/src/screens/Fahrten.tsx | 174 ++++++++++++++++ companion-app/src/screens/Karte.tsx | 81 ++++++++ companion-app/src/screens/Statistik.tsx | 155 ++++++++++++++ companion-app/src/screens/TankDetail.tsx | 239 ++++++++++++++++++++++ companion-app/src/screens/Tanken.tsx | 212 +++++++++++++++++++ companion-app/src/screens/Zeilenmenue.tsx | 54 +++++ companion-app/src/screens/register.tsx | 15 ++ companion-app/src/stile/screens.css | 135 ++++++++++++ 10 files changed, 1220 insertions(+) create mode 100644 companion-app/src/screens/FahrtDetail.tsx create mode 100644 companion-app/src/screens/Fahrten.tsx create mode 100644 companion-app/src/screens/Karte.tsx create mode 100644 companion-app/src/screens/Statistik.tsx create mode 100644 companion-app/src/screens/TankDetail.tsx create mode 100644 companion-app/src/screens/Tanken.tsx create mode 100644 companion-app/src/screens/Zeilenmenue.tsx diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index b11b69a..42ee522 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -18,6 +18,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 +92,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 { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_fahrt_manuell_anlegen", + { ts_start: tsStart, ts_end: tsEnde, art }, + "Fahrt eintragen", + ); + } + fahrtLoeschen(tripId: string): Promise { return this.warteschlange.einreihen( "pyscript", @@ -87,6 +111,26 @@ export class DataMetricApi { ); } + /** Felder eines Tankvorgangs, wie sie das Backend erwartet (deutsche + Parameternamen — belegverarbeitung.py). */ + tankvorgangAnlegen(felder: TankvorgangFelder): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_tankvorgang_manuell", + { ...felder }, + "Tankvorgang eintragen", + ); + } + + tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_tankvorgang_aktualisieren", + { tank_id: tankId, ...felder }, + "Tankvorgang ändern", + ); + } + tankvorgangLoeschen(tankId: string): Promise { return this.warteschlange.einreihen( "pyscript", diff --git a/companion-app/src/screens/FahrtDetail.tsx b/companion-app/src/screens/FahrtDetail.tsx new file mode 100644 index 0000000..a6a389d --- /dev/null +++ b/companion-app/src/screens/FahrtDetail.tsx @@ -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 ( + + ) + } + + const hatPositionen = fahrt.start_lat != null && fahrt.start_lon != null + + return ( + <> + + + {datum(fahrt.ts_start)} · {uhrzeit(fahrt.ts_start)} bis {uhrzeit(fahrt.ts_end)} Uhr + +
+
+ {deOderStrich(fahrt.distance_km, 1)} + km +
+
+ {fahrt.art === "arbeitsweg" ? Arbeitsweg : Privat} + {fahrt.status === "offen" && noch offen} + {fahrt.source === "manual" && selbst eingetragen} +
+
+ + + + + 0 + ? `${de((fahrt.distance_km / fahrt.duration_s) * 3600, 1)} km/h` + : "—" + } + /> + + } + /> +
+ + {fahrt.status === "offen" && ( +

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

+ )} + + + Strecke + {hatPositionen ? ( + + ) : ( + + )} + {(fahrt.start_address || fahrt.end_address) && ( + + + + + } + /> + )} + + + ) +} diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx new file mode 100644 index 0000000..4eb0f99 --- /dev/null +++ b/companion-app/src/screens/Fahrten.tsx @@ -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 ( + <> +
+ + {fahrten.length === 0 + ? "Keine Fahrten" + : `${de(fahrten.length)} Fahrt${fahrten.length === 1 ? "" : "en"}`} + + +
+ + {formularOffen && setzeFormularOffen(false)} />} + + {fahrten.length === 0 && !formularOffen && ( + setzeFormularOffen(true) }} + /> + )} + + {gruppen.map((jahr) => ( + f.distance_km ?? 0))} km`} + defaultOpen={jahr === gruppen[0]} + > + {jahr.monate.map((monat) => ( + f.distance_km ?? 0))} km`} + defaultOpen={monat === jahr.monate[0]} + > + {monat.eintraege.map((fahrt) => ( + void api.fahrtLoeschen(fahrt.trip_id)} + kinder={ + + } + /> + ))} + + ))} + + ))} + + ) +} + +/** 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(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 ( + + Fahrt eintragen + + setzeStart(e.target.value)} + /> + + + setzeEnde(e.target.value)} + /> + + + + + {fehler && ( +

+ {fehler} +

+ )} +

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

+
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} diff --git a/companion-app/src/screens/Karte.tsx b/companion-app/src/screens/Karte.tsx new file mode 100644 index 0000000..8e0901f --- /dev/null +++ b/companion-app/src/screens/Karte.tsx @@ -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(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
+} diff --git a/companion-app/src/screens/Statistik.tsx b/companion-app/src/screens/Statistik.tsx new file mode 100644 index 0000000..c485e20 --- /dev/null +++ b/companion-app/src/screens/Statistik.tsx @@ -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 ( + + ) + } + + const a = jahresauswertung( + fahrten, + tankvorgaenge, + einstellungen.nachtVon, + einstellungen.nachtBis, + ) + + return ( + <> + + Distanz und Fahrzeit {a.jahr} + + ({ + value: de(a.jeZeitraum[z].km), + unit: "km", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + ({ + value: de(a.jeZeitraum[z].stunden, z === "jahr" || z === "monat" ? 0 : 1), + unit: "h", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + + + + Getankte Menge und Kosten {a.jahr} + + ({ + value: de(a.jeZeitraum[z].liter), + unit: "l", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + ({ + value: de(a.jeZeitraum[z].kosten), + unit: "€", + caption: ZEITRAUM_NAME[z], + }))} + columns={4} + /> + + + + Langzeitverbrauch + + + + + + + } + /> + {a.langzeit.unplausibel && ( +

+ Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und + Kilometerstände vollständig vorliegen, erscheint er hier. +

+ )} +

+ 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. +

+
+ + + Tag und Nacht {a.jahr} + 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"} + unit="% bei Tag" + size={40} + /> + +

+ Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr +

+
+ + + Art der Fahrten {a.jahr} + 0 ? de(Math.round((a.arbeitKm / a.kmJahr) * 100)) : "—"} + unit="% Arbeitsweg" + size={40} + /> + + + + + + + } + /> + + + ) +} diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx new file mode 100644 index 0000000..e795fca --- /dev/null +++ b/companion-app/src/screens/TankDetail.tsx @@ -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 ( + + ) + } + + return ( + <> + + {datumZeit(tank.ts)} +
+
+ {deOderStrich(tank.liters, 2)} + l +
+
+ {tank.status === "unvollständig" && unvollständig} + {tank.source === "beleg" && aus Beleg} + {tank.source === "manual" && selbst eingetragen} + {tank.source === "auto" && automatisch erkannt} +
+
+ + + + {tank.discount != null && tank.discount > 0 && ( + + )} + + + + + + } + /> +
+ + + + ) +} + +function BelegBereich({ + tankId, + belegDatei, + belegNr, + zeitpunkt, + api, +}: { + tankId: string + belegDatei: string | null + belegNr: string | null + zeitpunkt: string + api: ReturnType["api"] +}) { + const dateiwahl = useRef(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(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 ( + + Beleg + {belegDatei ? ( + + + + + } + /> + ) : ( +

+ Für diese Tankung liegt noch kein Beleg vor. Aus einem Shell-Beleg liest die App Menge, + Betrag, Tankstelle und Ersparnis selbst aus. +

+ )} + + { + const datei = e.target.files?.[0] + if (datei) void hochladen(datei) + e.target.value = "" + }} + /> + + {meldung && ( +

+ {meldung.text} +

+ )} + +
+ dateiwahl.current?.click()} disabled={laeuft || wartet}> + {laeuft ? "Lade hoch …" : wartet ? "Beleg wird gelesen …" : belegDatei ? "Beleg ersetzen" : "Beleg hochladen"} + +
+

+ Der Beleg muss vom {datum(zeitpunkt)} sein — sonst weist der Server ihn ab, damit keine + fremde Tankung in diesen Eintrag gerät. +

+
+ ) +} + +/** Datei als Base64 ohne den `data:`-Vorspann — so erwartet es das Backend. */ +function alsBase64(datei: File): Promise { + 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) + }) +} diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx new file mode 100644 index 0000000..a22efbf --- /dev/null +++ b/companion-app/src/screens/Tanken.tsx @@ -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 ( + <> +
+ + {tankvorgaenge.length === 0 + ? "Keine Tankvorgänge" + : `${de(tankvorgaenge.length)} Tankvorgang${tankvorgaenge.length === 1 ? "" : "̈e"}`} + + +
+ + {formularOffen && setzeFormularOffen(false)} />} + + {tankvorgaenge.length === 0 && !formularOffen && ( + setzeFormularOffen(true) }} + /> + )} + + {gruppen.map((jahr) => ( + t.fuel_total_eur ?? 0))} €`} + defaultOpen={jahr === gruppen[0]} + > + {jahr.monate.map((monat) => { + const schnitt = schnittpreis(monat.eintraege) + return ( + 0 ? `${eur(schnitt)} €/l` : "—"} + defaultOpen={monat === jahr.monate[0]} + > + {monat.eintraege.map((tank) => ( + void api.tankvorgangLoeschen(tank.tank_id)} + kinder={ + + } + /> + ))} + + ) + })} + + ))} + + ) +} + +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(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 ( + + Tankung eintragen + + setzeZeitpunkt(e.target.value)} + /> + + + setzeLiter(e.target.value)} + /> + + + setzeKosten(e.target.value)} + /> + + + setzeStation(e.target.value)} + /> + + + setzeKm(e.target.value)} + /> + + {fehler && ( +

+ {fehler} +

+ )} +

+ 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. +

+
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} diff --git a/companion-app/src/screens/Zeilenmenue.tsx b/companion-app/src/screens/Zeilenmenue.tsx new file mode 100644 index 0000000..1a9b38d --- /dev/null +++ b/companion-app/src/screens/Zeilenmenue.tsx @@ -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 ( +
+ + {kinder} + + + setzeMenueOffen(false)} variant="menu" anchor="center"> + + Löschen + + setzeMenueOffen(false)}>Abbrechen + +
+ ) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index 4f71172..ca9b03c 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -5,9 +5,14 @@ import { useDaten } from "../daten/DatenKontext" import type { Route, SeitenName } from "../navigation" +import { FahrtDetail } from "./FahrtDetail" +import { Fahrten } from "./Fahrten" import { Fahrzeugstatus } from "./Fahrzeugstatus" import { MeinAudi } from "./MeinAudi" import { Platzhalter } from "./Platzhalter" +import { Statistik } from "./Statistik" +import { TankDetail } from "./TankDetail" +import { Tanken } from "./Tanken" import { Uebersicht } from "./Uebersicht" export interface SeitenProps { @@ -44,6 +49,16 @@ export function SeiteFuer(props: SeitenProps) { return case "audi": return + case "trips": + return + case "trip": + return + case "fuel": + return + case "fill": + return + case "stat": + return default: return } diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 03d5cc7..611dc58 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -352,3 +352,138 @@ gap: var(--sp-2); margin-top: var(--sp-3); } + +/* ------------------------------------------------------- Listenzeilen */ + +.dm-zeile { + display: flex; + align-items: stretch; + gap: 2px; +} + +.dm-zeile__wisch { + flex: 1; + min-width: 0; +} + +/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn + waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */ +.dm-zeile__menue { + flex: none; + width: 44px; + border: 0; + background: transparent; + color: var(--fg3); + border-radius: var(--r-func); + cursor: pointer; +} + +.dm-zeile__menue:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-listenzeile { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 12px 2px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.dm-listenzeile__haupt { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.dm-listenzeile__titel { + display: flex; + align-items: baseline; + gap: var(--sp-2); + font-size: 16px; + font-weight: 300; +} + +.dm-listenzeile__neben { + font-size: 13px; + color: var(--fg2); +} + +.dm-listenzeile__unter { + font-size: 12px; + font-weight: 300; + color: var(--fg3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* -------------------------------------------------------- Detailseiten */ + +.dm-detail__kopf { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); + margin: var(--sp-2) 0 var(--sp-3); + flex-wrap: wrap; +} + +.dm-detail__zahl { + font-size: 40px; + font-weight: 300; + line-height: 1; + letter-spacing: -0.02em; +} + +.dm-detail__einheit { + font-size: 15px; + color: var(--fg2); + margin-left: 6px; +} + +.dm-detail__marken { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.dm-karte { + height: 240px; + width: 100%; + border-radius: var(--r-func); + overflow: hidden; + margin-top: var(--sp-3); + background: var(--tile-2); +} + +/* ------------------------------------------------------------ Meldungen */ + +.dm-fehler { + margin: var(--sp-3) 0 0; + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--bad); +} + +.dm-erfolg { + margin: var(--sp-3) 0 0; + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--ok); +} + +.dm-fussnote--warnung { + color: var(--warn); +} From 8e6e5bc2ee7e58003227d2a54ce3f4d8aa453d0a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:17:47 +0200 Subject: [PATCH 09/18] Phase 7c: restliche Screens - alle 21 Seiten stehen Fahrzeugdaten, Batterieverlauf, Service mit Werkstatt und Servicebuch, Reifen, die ganze Versicherungs- und Steuergruppe, Einstellungen in voller Tiefe und die Live-Ansicht. Portiert dazu: die eigene Oelwechsel-Prognose (rechnet ab dem letzten Wechsel im Servicebuch neu, weil der Langzeitschnitt dafuer zu traege ist), Kalenderdateien fuer Termine und die CSV-Ausgabe im deutschen Format. Die Live-Ansicht ist gebaut, aber ueber einen Schalter abgeschaltet: die Werte dafuer liefert erst der FMM003. Ein Menuepunkt mit lauter Nullen waere schlechter als keiner. Zwei Stellen, an denen die App bewusst zurueckhaltend ist: ein unbekannter Pruefpunkt erscheint als hohler Ring statt als geratenes Gruen, und der Reifen-Kilometerstand wird nie zurueckgeschrieben, weil ihn der Server fortschreibt. 49 Tests, darunter ein Rendertest ueber alle 21 Seiten in beiden Layouts mit Beispieldaten in der Form, die das Backend wirklich liefert. --- companion-app/src/daten/service.ts | 96 +++++ companion-app/src/funktionen.ts | 17 + companion-app/src/screens/Batterie.tsx | 177 +++++++++ companion-app/src/screens/Einstellungen.tsx | 357 +++++++++++++++++ companion-app/src/screens/Fahrzeugdaten.tsx | 103 +++++ companion-app/src/screens/LiveFahrt.tsx | 87 +++++ companion-app/src/screens/Platzhalter.tsx | 50 --- companion-app/src/screens/Reifen.tsx | 157 ++++++++ companion-app/src/screens/Service.tsx | 311 +++++++++++++++ companion-app/src/screens/Versicherung.tsx | 404 ++++++++++++++++++++ companion-app/src/screens/csv.ts | 33 ++ companion-app/src/screens/kalender.ts | 64 ++++ companion-app/src/screens/register.tsx | 53 ++- companion-app/src/screens/screens.test.tsx | 109 ++++++ companion-app/src/stile/screens.css | 81 ++++ companion-app/src/tests/beispieldaten.ts | 213 +++++++++++ 16 files changed, 2258 insertions(+), 54 deletions(-) create mode 100644 companion-app/src/daten/service.ts create mode 100644 companion-app/src/funktionen.ts create mode 100644 companion-app/src/screens/Batterie.tsx create mode 100644 companion-app/src/screens/Einstellungen.tsx create mode 100644 companion-app/src/screens/Fahrzeugdaten.tsx create mode 100644 companion-app/src/screens/LiveFahrt.tsx delete mode 100644 companion-app/src/screens/Platzhalter.tsx create mode 100644 companion-app/src/screens/Reifen.tsx create mode 100644 companion-app/src/screens/Service.tsx create mode 100644 companion-app/src/screens/Versicherung.tsx create mode 100644 companion-app/src/screens/csv.ts create mode 100644 companion-app/src/screens/kalender.ts create mode 100644 companion-app/src/screens/screens.test.tsx create mode 100644 companion-app/src/tests/beispieldaten.ts diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts new file mode 100644 index 0000000..7f4a965 --- /dev/null +++ b/companion-app/src/daten/service.ts @@ -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, + } +} diff --git a/companion-app/src/funktionen.ts b/companion-app/src/funktionen.ts new file mode 100644 index 0000000..4fecf52 --- /dev/null +++ b/companion-app/src/funktionen.ts @@ -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 diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx new file mode 100644 index 0000000..d46dbb0 --- /dev/null +++ b/companion-app/src/screens/Batterie.tsx @@ -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(null) + const [fehler, setzeFehler] = useState(false) + + useEffect(() => { + void api.rest + .datenLesen(ENTITAETEN.batterieverlauf) + .then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : [])) + .catch(() => setzeFehler(true)) + }, [api]) + + if (fehler) { + return ( + + ) + } + + if (!verlauf) return
Verlauf wird geladen …
+ + const messwerte = verlauf.filter((t) => t.min != null) + + if (messwerte.length === 0) { + return ( + + ) + } + + 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 ( + <> + + Ruhespannung +
+
+ {letzter.min != null ? de(letzter.min, 2) : "—"} + V +
+
+ + + + + = 0 ? "+" : ""}${de(trend, 2)} V` : "—"} + /> + + + } + /> +

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

+
+ + ) +} + +/** 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 ( + + t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" /> + t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" /> + + ) +} diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx new file mode 100644 index 0000000..31f876e --- /dev/null +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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(null) + const [laeuft, setzeLaeuft] = useState(false) + const [gespeichert, setzeGespeichert] = useState(false) + const dateiwahl = useRef(null) + + if (!einstellungen || !fahrzeug) return null + const werte = entwurf ?? einstellungen + + const aendern = (teil: Partial) => + 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 ( + <> + + Fahrzeug + + aendern({ fahrzeugtitel: e.target.value })} + /> + + + aendern({ fahrzeugzusatz: e.target.value })} + /> + + + aendern({ kennzeichen: e.target.value })} + /> + + + aendern({ tankvolumen: Number(e.target.value) || 0 })} + /> + + + aendern({ wlanName: e.target.value })} + /> + + + + + Fahrten + + aendern({ pauseMin: Number(e.target.value) || 0 })} + /> + + + aendern({ nachtVon: e.target.value })} + /> + + + aendern({ nachtBis: e.target.value })} + /> + +

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

+
+ + + Darstellung + + themeSetzen(neu as "nacht" | "tag")} + /> + + + + + + + + Bild auf der Übersicht + + + +
+ {BILDPLAETZE.map((platz) => ( + + ))} +
+

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

+
+ + + Tankrabatt + + + void profilSpeichern({ + fahrzeug: { + ...fahrzeug, + smartdeal: { ...fahrzeug.smartdeal, aktiv: an }, + }, + }) + } + aria-label="Tankrabatt aktiv" + /> + + + + void profilSpeichern({ + fahrzeug: { + ...fahrzeug, + smartdeal: { ...fahrzeug.smartdeal, laeuftAb: e.target.value || null }, + }, + }) + } + /> + +

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

+
+ + + Sicherung + + + + + } + /> +
+ + void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt") + } + > + Jetzt sichern + + Auf dieses Gerät laden +
+
+ + + Daten ausgeben +

+ Als CSV mit Semikolon und deutschem Zahlenformat — so öffnet Excel die Datei ohne + Nachfragen. +

+
+ + 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 + + + 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 + +
+
+ + + Zugang + + + + + } + /> +
+ void abmelden()}> + Verbindung trennen + +
+
+ + + + {gespeichert &&

Gespeichert.

} + {entwurf && ( +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Änderungen speichern"} + + +
+ )} + + ) +} diff --git a/companion-app/src/screens/Fahrzeugdaten.tsx b/companion-app/src/screens/Fahrzeugdaten.tsx new file mode 100644 index 0000000..bee5242 --- /dev/null +++ b/companion-app/src/screens/Fahrzeugdaten.tsx @@ -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 ( + <> + + Identität + + + + + + + + + + } + /> + + + {technik.length > 0 && ( + + Technische Daten + {technik.map((gruppe, i) => ( + + ( + + ))} + /> + + ))} + + )} + + {ausstattung.length > 0 && ( + + Ausstattung + {ausstattung.map((gruppe, i) => ( + +
    + {(gruppe.sonder ?? []).map((posten) => ( +
  • {posten}
  • + ))} + {(gruppe.serie ?? []).map((posten) => ( +
  • + {posten} +
  • + ))} +
+
+ ))} +
+ )} + + ) +} diff --git a/companion-app/src/screens/LiveFahrt.tsx b/companion-app/src/screens/LiveFahrt.tsx new file mode 100644 index 0000000..a228a46 --- /dev/null +++ b/companion-app/src/screens/LiveFahrt.tsx @@ -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): 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) + + if (!live.faehrt) { + return ( + + ) + } + + return ( + <> + + Geschwindigkeit + + + + + + } + /> + + + + Position + {live.position ? ( + + ) : ( + + )} + + + ) +} diff --git a/companion-app/src/screens/Platzhalter.tsx b/companion-app/src/screens/Platzhalter.tsx deleted file mode 100644 index 9329746..0000000 --- a/companion-app/src/screens/Platzhalter.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Vorläufiger Inhalt aller Seiten — wird in Phase 7 des Umsetzungsplans Seite - * für Seite durch die echten Ansichten ersetzt. Bis dahin belegt er die - * Navigation, damit sich der Rahmen bedienen lässt. - */ - -import { Tile } from "@audi-dash/ui" - -import type { Route, SeitenName } from "../navigation" -import { TITEL } from "../navigation" - -const UNTERSEITEN: Partial> = { - home: ["sicherheit", "einst"], - audi: ["ident", "battverlauf", "service", "reifen", "vers"], - service: ["werkstatt", "sbuch"], - vers: ["beitrag", "vertragsdetails", "steuer", "notruf"], - vertragsdetails: ["schutz"], - trips: ["trip"], - fuel: ["fill"], -} - -export function Platzhalter({ - route, - geheZu, -}: { - route: Route - geheZu: (name: SeitenName, id?: string) => void -}) { - const weiter = UNTERSEITEN[route.name] ?? [] - return ( - <> - -
{TITEL[route.name]}
-

- Diese Seite wird noch gebaut. -

-
- {weiter.length > 0 && ( -
- {weiter.map((ziel) => ( - geheZu(ziel)}> - weiter -
{TITEL[ziel]}
-
- ))} -
- )} - - ) -} diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx new file mode 100644 index 0000000..2c3c248 --- /dev/null +++ b/companion-app/src/screens/Reifen.tsx @@ -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 ( + <> + + Montiert +
+
{aktiv}
+ void wechseln()} disabled={laeuft}> + {laeuft ? "Stelle um …" : `Auf ${aktiv === "Sommer" ? "Winter" : "Sommer"} wechseln`} + +
+ + + + + } + /> + + void wechseldatumSetzen(e.target.value)} + /> + + {wechsel.datum && ( +
+ + kalenderDateiLaden( + "Reifenwechsel", + wechsel.datum!, + `Wechsel auf ${aktiv === "Sommer" ? "Winter" : "Sommer"}reifen`, + ) + } + > + In den Kalender eintragen + +
+ )} +
+ + setzeGezeigt(wert as "Sommer" | "Winter")} + /> + + + + {anzeige}reifen {anzeige === aktiv ? "· montiert" : "· eingelagert"} + + + + + + + + + {Boolean(satz["kommentar"]) && ( + + )} + + } + /> +

+ Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und + rechnet sie immer dem gerade montierten Satz zu. +

+
+ + ) +} diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx new file mode 100644 index 0000000..55e7a86 --- /dev/null +++ b/companion-app/src/screens/Service.tsx @@ -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 ( + <> + + Nächster Ölwechsel + + + + + + + } + /> + {prognose && ( +
+ + kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose") + } + > + In den Kalender eintragen + +
+ )} +

+ 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. +

+
+ +
+ geheZu("werkstatt")} + /> + geheZu("sbuch")} + /> +
+ + ) +} + +export function Werkstatt() { + const { fahrzeug, profilSpeichern } = useDaten() + const autohausRoh = (fahrzeug?.service["autohaus"] ?? {}) as Autohaus + const [entwurf, setzeEntwurf] = useState({ ...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) => ( + + setzeEntwurf((alt) => ({ ...alt, [schluessel]: e.target.value }))} + /> + + ) + + return ( + + Werkstatt + {feld("name", "Name")} + {feld("adresse", "Adresse")} + {feld("tel", "Telefon")} + {feld("mail", "E-Mail", true)} + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +export function Servicebuch() { + const { fahrzeug, profilSpeichern } = useDaten() + const [neuOffen, setzeNeuOffen] = useState(false) + const [entwurf, setzeEntwurf] = useState({ 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 ( + <> +
+ {de(buch.length)} Einträge + +
+ + {neuOffen && ( + + Neuer Eintrag + + setzeEntwurf((alt) => ({ ...alt, datum: e.target.value }))} + /> + + + + + + setzeEntwurf((alt) => ({ ...alt, km: Number(e.target.value) }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, betrieb: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, notiz: e.target.value }))} + /> + +
+ void hinzufuegen()} disabled={laeuft}> + Speichern + +
+
+ )} + + {buch.length === 0 && !neuOffen && ( + setzeNeuOffen(true) }} + /> + )} + + {buch.map((eintrag, index) => ( + +
+
+ {eintrag.datum ? datum(eintrag.datum) : "ohne Datum"} +
+ {eintrag.art ?? "Eintrag"} +
+
+ +
+ + + + {eintrag.notiz && } + + } + /> +
+ ))} + + ) +} diff --git a/companion-app/src/screens/Versicherung.tsx b/companion-app/src/screens/Versicherung.tsx new file mode 100644 index 0000000..92b1f1b --- /dev/null +++ b/companion-app/src/screens/Versicherung.tsx @@ -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 ( + <> + + Versicherung + + + + + + + + } + /> + + +
+ geheZu("beitrag")} /> + geheZu("vertragsdetails")} + /> + geheZu("notruf")} + /> + geheZu("steuer")} + /> +
+ + ) +} + +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(() => 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 ( + + ) + } + + return ( + + Beitragsposten + {entwurf.map((posten, index) => ( + + + setzeEntwurf((alt) => + alt.map((p, i) => + i === index ? { ...p, betrag: Number(e.target.value.replace(",", ".")) } : p, + ), + ) + } + /> + + ))} + s + (Number(t.betrag) || 0), 0))} €`} + /> + } + /> + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +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 ( + <> + + Leistungen + {leistungen.length === 0 ? ( +

Im Profil sind noch keine Leistungen hinterlegt.

+ ) : ( + ( + + ))} + /> + )} +
+ + {selbst.length > 0 && ( + + Selbstbeteiligung + ( + + ))} + /> + + )} + + geheZu("schutz")} + /> + + ) +} + +export function Schutzbrief() { + const { fahrzeug } = useDaten() + if (!fahrzeug) return null + + const s = (fahrzeug.versicherung["schutzbrief"] ?? {}) as Record + 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 ( + + ) + } + + return ( + <> + + Schutzbrief + + + + + + } + /> + + {listen + .filter(([, eintraege]) => eintraege.length > 0) + .map(([titel, eintraege]) => ( + + {titel} +
    + {eintraege.map((eintrag) => ( +
  • {eintrag}
  • + ))} +
+
+ ))} + + ) +} + +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 ( + + Nummern im Notfall + + setzeService(e.target.value)} + /> + + + setzeNotfall(e.target.value)} + /> + + {(service || notfall) && ( +
+ {service && ( + + Service anrufen + + )} + {notfall && ( + + Notfall anrufen + + )} +
+ )} + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+
+ ) +} + +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 ( + + Kfz-Steuer + + + setzeEntwurf((alt) => ({ ...alt, betrag: Number(e.target.value.replace(",", ".")) })) + } + /> + + + setzeEntwurf((alt) => ({ ...alt, zeitraum: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, faellig: e.target.value }))} + /> + + + setzeEntwurf((alt) => ({ ...alt, finanzamt: e.target.value }))} + /> + + + + + + + } + /> + {gespeichert &&

Gespeichert.

} +
+ void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} + +
+

+ {de(1)} Fahrzeug, {(entwurf["zeitraum"] as string) || "jährlich"} zu zahlen. +

+
+ ) +} diff --git a/companion-app/src/screens/csv.ts b/companion-app/src/screens/csv.ts new file mode 100644 index 0000000..2f6de90 --- /dev/null +++ b/companion-app/src/screens/csv.ts @@ -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) +} diff --git a/companion-app/src/screens/kalender.ts b/companion-app/src/screens/kalender.ts new file mode 100644 index 0000000..c05865b --- /dev/null +++ b/companion-app/src/screens/kalender.ts @@ -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) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index ca9b03c..5ff424c 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -5,15 +5,28 @@ 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 { Platzhalter } from "./Platzhalter" +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 @@ -40,7 +53,7 @@ export function SeiteFuer(props: SeitenProps) { return
Daten werden geladen …
} - const { route, geheZu } = props + const { route, geheZu, tabBeschriftung, setzeTabBeschriftung, beiAbmeldung } = props switch (route.name) { case "home": @@ -49,6 +62,30 @@ export function SeiteFuer(props: SeitenProps) { return case "audi": return + case "ident": + return + case "battverlauf": + return + case "service": + return + case "werkstatt": + return + case "sbuch": + return + case "reifen": + return + case "vers": + return + case "beitrag": + return + case "vertragsdetails": + return + case "schutz": + return + case "notruf": + return + case "steuer": + return case "trips": return case "trip": @@ -59,7 +96,15 @@ export function SeiteFuer(props: SeitenProps) { return case "stat": return - default: - return + case "live": + return + case "einst": + return ( + + ) } } diff --git a/companion-app/src/screens/screens.test.tsx b/companion-app/src/screens/screens.test.tsx new file mode 100644 index 0000000..8eb0c9f --- /dev/null +++ b/companion-app/src/screens/screens.test.tsx @@ -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( +
+ + {}} + tabBeschriftung + > + {}} + tabBeschriftung + setzeTabBeschriftung={() => {}} + beiAbmeldung={() => {}} + /> + + +
, + ) +} + +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() + }) +}) diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 611dc58..fa6c08f 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -487,3 +487,84 @@ .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); +} diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts new file mode 100644 index 0000000..f7addbb --- /dev/null +++ b/companion-app/src/tests/beispieldaten.ts @@ -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 +} From 571d12310ffe6fb35018e77edd031d8826a075be Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:19:38 +0200 Subject: [PATCH 10/18] Phase 8: Audi-Schrift, Vier-Ringe und Typenschilder Die drei Schriftschnitte stammen aus dem Panel, wo sie als base64 im Stylesheet lagen - als eigene woff2-Dateien sind sie zwischenspeicherbar statt bei jedem Laden erneut uebertragen zu werden. Typenschilder und Ringe wechseln mit dem Erscheinungsbild. Lizenzgrenze eingehalten und geprueft: design-system bleibt unveraendert und enthaelt keine Markendateien, weil es nach aussen hochgeladen wird. Die App darf sie nutzen, weil sie nur seitlich installiert wird. Dabei noch eine Typungenauigkeit korrigiert: technik und ausstattung im Profil sind Listen von Gruppen, waren aber wie die uebrigen Abschnitte als Objekt deklariert. --- companion-app/src/Shell.tsx | 6 +- companion-app/src/api/types.ts | 7 +- .../src/assets/audi/audi-rings-black.svg | 11 +++ .../src/assets/audi/audi-rings-white.svg | 11 +++ companion-app/src/assets/audi/audi-sport.svg | 1 + .../assets/audi/schriften/audi-type-400.woff2 | Bin 0 -> 22868 bytes .../audi-type-extended-400-italic.woff2 | Bin 0 -> 15496 bytes .../audi/schriften/audi-type-wide-300.woff2 | Bin 0 -> 15324 bytes .../audi/typenschilder/rs3-negative.svg | 1 + .../audi/typenschilder/rs3-positive.svg | 1 + .../audi/typenschilder/rs4-negative.svg | 1 + .../audi/typenschilder/rs4-positive.svg | 1 + .../audi/typenschilder/rs5-negative.svg | 1 + .../audi/typenschilder/rs5-positive.svg | 1 + .../audi/typenschilder/rs6-negative.svg | 1 + .../audi/typenschilder/rs6-positive.svg | 1 + .../audi/typenschilder/rs7-negative.svg | 1 + .../audi/typenschilder/rs7-positive.svg | 1 + .../assets/audi/typenschilder/s3-negative.svg | 1 + .../assets/audi/typenschilder/s3-positive.svg | 1 + .../audi/typenschilder/sq7-negative.svg | 1 + .../audi/typenschilder/sq7-positive.svg | 1 + companion-app/src/daten/profilAdapter.test.ts | 4 +- companion-app/src/main.tsx | 1 + companion-app/src/screens/Typenschild.tsx | 52 +++++++++++++++ companion-app/src/screens/Uebersicht.tsx | 14 ++-- companion-app/src/stile/audi-schrift.css | 63 ++++++++++++++++++ companion-app/src/stile/rahmen.css | 14 ++++ 28 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 companion-app/src/assets/audi/audi-rings-black.svg create mode 100644 companion-app/src/assets/audi/audi-rings-white.svg create mode 100644 companion-app/src/assets/audi/audi-sport.svg create mode 100644 companion-app/src/assets/audi/schriften/audi-type-400.woff2 create mode 100644 companion-app/src/assets/audi/schriften/audi-type-extended-400-italic.woff2 create mode 100644 companion-app/src/assets/audi/schriften/audi-type-wide-300.woff2 create mode 100644 companion-app/src/assets/audi/typenschilder/rs3-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs3-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs4-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs4-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs5-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs5-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs6-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs6-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs7-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/rs7-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/s3-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/s3-positive.svg create mode 100644 companion-app/src/assets/audi/typenschilder/sq7-negative.svg create mode 100644 companion-app/src/assets/audi/typenschilder/sq7-positive.svg create mode 100644 companion-app/src/screens/Typenschild.tsx create mode 100644 companion-app/src/stile/audi-schrift.css diff --git a/companion-app/src/Shell.tsx b/companion-app/src/Shell.tsx index 886c081..99d5b72 100644 --- a/companion-app/src/Shell.tsx +++ b/companion-app/src/Shell.tsx @@ -12,6 +12,7 @@ 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 { @@ -79,7 +80,10 @@ export function Shell({ route, geheZu, tabBeschriftung, hinweis, children }: She return ( <>