diff --git a/AGENTS.md b/AGENTS.md index c1d2493..e9e7a42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-08-13** (design/CI review added, see `DESIGN_REVIEW_2026-08-13.md`). This file is the entry point for every new agent +**Last updated: 2026-08-13** (merged the `umsetzung-datametric360` branch — companion app phases +1–10 done, see `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in the linked documents — this file points, it does not duplicate. @@ -73,19 +74,22 @@ assets. Never mix these the other way around. **Reading order for a new session:** 1. This file (overview + open items) -2. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps" -3. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, barely built) -4. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below) -5. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7) +2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with + commands and acceptance criteria); when working on an open item, follow the plan's phase +3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps" +4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 1–10 built) +5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below) +6. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7) ## The three projects in this repo | Area | What | Status | |---|---|---| -| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** | +| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app | +| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible | | `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) | -| `companion-app/` | **DataMetric360** — successor app (Capacitor iOS/Android + HA iframe); will **replace** the panel | 🚧 data layer only (~930 lines TS), **no UI** | -| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim (wrong model, main screens only) | +| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data | +| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) | Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only), `bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the @@ -103,12 +107,12 @@ Fahrzeug einrichten → Setup), stored as overrides in `data/entitaeten.json`; `pyscript/modules/einstellungen.py` holds only the built-in defaults. Deploy: `update.ps1` (robocopy to Samba share) or — still inactive — self-update from git. -**`companion-app/` — what exists:** dependency-free, strictly typed TS data layer (`src/api/`): -REST client (`rest.ts`), WebSocket client with auth flow + reconnect backoff (`live.ts`), -persistent offline write queue (`warteschlange.ts`, strict FIFO), environment detection -capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), facade -`DataMetricApi` (`index.ts`). Smoke test ran 7/7 green against Docker HA `audi_ha_test` -(localhost:18123) — unauthenticated only; token-authenticated reads/writes untested. +**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with +reconnect backoff, persistent offline write queue, credential storage), domain logic +(`src/daten/`: profile adapter, statistics, service forecast, data context), all 21 screens +(`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and icons. Verified by 90 unit +and render tests plus 9 checks against a live Home Assistant. `npm run dev` in the repo root +starts it; `testumgebung/aufsetzen.sh` provides the server side. **DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):** - Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor @@ -170,7 +174,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. frontend: no tests, no CI. - **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel. -### Documentation drift (small fixes; align docs to code) +### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong) - `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment `audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**; @@ -179,19 +183,21 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. `belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files. - Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed). -### Audit leftovers (`AUDIT_2026-08-10.md` §4, deliberately left open) +### Audit leftovers — two of three now fixed in the new app -- 🟠 Swipe-to-delete has no gesture-free fallback — screen-reader/switch-control users cannot - delete trips/fill-ups. Needs a design decision (long-press vs. "…" button vs. action sheet). -- 🟡 Popup close-by-tap-outside is not keyboard-reachable (needs a quick manual check). -- 🟡 Self-host Leaflet JS/CSS (tiles necessarily stay remote). +- ✅ Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also + carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in + the old panel, which is being replaced anyway. +- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk. +- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's + Popup, which is keyboard-reachable). - Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel gets replaced anyway. -### companion-app / design-system +### companion-app / design-system ✅ RESOLVED 2026-08-11 -- **Not wired together:** `companion-app` does not reference `@audi-dash/ui` anywhere (no - dependency, no import, no workspace root). The link exists only in prose. +- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the + app as a real dependency. - Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first (design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`). - No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system). @@ -201,24 +207,100 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. --- +### Found while building (2026-08-11) — all fixed + +Three defects that only surfaced by running against a real Home Assistant, not by reading code: + +1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had + `tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes + `tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without + anything failing. `technik`/`ausstattung` were typed as objects but are arrays. +2. **The profile adapter handed out live references into the raw profile.** Editing a form would + have silently mutated the baseline and broken the promise never to overwrite the + backend-maintained tire odometer. Sections are copied now. +3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG + AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a + already-solved contrast defect. + +Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is +what the smoke scripts run on — rewritten as plain fields. + +Three more that only screenshots revealed — nothing failed, the pixels were simply wrong: + +4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's + automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed + the block to zero height and took model name, badge and plate with it. Fixed with + `.dm-inhalt > * { flex: none }`. +5. **The model name appeared twice** — once as the badge image, once as text beside it. +6. **Filenames overflowed the gallery thumbnails.** + +And one in the backend, found by watching the log against a current Home Assistant: the odometer +screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip +stayed without a distance, visible only as a warning. Now runs through `task.executor`. + +**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA +2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served +from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the +native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not +apply. Verify this again at commissioning if the app ever moves to a separate hostname. + +**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239 +of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a +vendored MIT library served from Home Assistant itself, which satisfies the actual requirement +(no network call, token never leaves the local network) and round-trips correctly. + +## Pending: this branch has diverged from `main` (noted 2026-08-13) + +`ha_install.md` (root) plans the move from pyscript to a **native HA integration** with a config +flow — UI-only setup, no YAML, and updates via a self-reporting `UpdateEntity` against the Gitea +repo (HACS is GitHub-only, so it is not an option). All APIs in it were verified against the +running 2026.8.1 instance. It also records which review findings that move eliminates by design. + +A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the +three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole +mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor, +which makes "securely parked" report safe while three doors were never checked). + +`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup +menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the +owner keeps working on `main` first. + +Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`, +`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly. + +**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in +`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or +corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so +it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`, +`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and +would raise `AttributeError` on a missing profile instead of logging a clear error. + +When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to +every remaining `profil.profil_lesen()` call site. + ## Open items +Execution order, exact steps, and acceptance criteria for every item below live in +`UMSETZUNGSPLAN.md` (phases 1–13). Additional decisions of 2026-08-11: **no Electron** (Capacitor +wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and +**no separate backend** (the app talks to the HA REST/WebSocket API directly). + ### A) Build DataMetric360 (the big block) -- [ ] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages; +- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages; then re-export to `design/` -- [ ] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real +- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real dependency (possibly add a workspace/monorepo root) -- [ ] Implement the screens from the design draft on top of the existing `DataMetricApi` layer -- [ ] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` — +- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer +- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` — **never** into `design-system/` -- [ ] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the +- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the `ablageSetzen()` hook already exists) -- [ ] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated +- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated locally under HA `/local/`, architecture §3) -- [ ] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip) +- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip) against `audi_ha_test` with a real token -- [ ] Offline UX per design brief (offline marker, visible pending queue) +- [x] Offline UX per design brief (offline marker, visible pending queue) ### B) Infrastructure / commissioning (partly waits for FMM003 hardware) @@ -227,7 +309,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. - [ ] Decide hostname split (app on apex + API on `api.` subdomain, or vice versa) - [ ] Choose reverse proxy (Nginx Proxy Manager vs. Traefik) — **can be done before hardware** against the existing HA API -- [ ] Define the reverse-proxy path allowlist (depends on final entity/service names) +- [x] Define the reverse-proxy path allowlist (depends on final entity/service names) - [ ] Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent) - [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`). @@ -420,9 +502,20 @@ formatting hold everywhere; `design-system/` is verifiably brand-free. was left unexercised beyond confirming it registers without error at pyscript load time. - [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) — text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed - 2026-08-12 (see section B) -- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` -- [ ] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 + 2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md` + list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`, + `homeassistant/README.md`, the profile template) +- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the + `umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns + `None` on a missing or malformed file instead of raising; all 5 call sites + (`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`, + `tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no + caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md` + §"Was den Branch betrifft"). +- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided + 2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard + access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in + DataMetric360. - [ ] Optional: persist the RAM-only states (trip start, fuel low-water-mark) — deliberately deferred; may become moot with the FMM003 switch - [ ] Upload vehicle photos to `www/bilder/`, set `steuer.faellig` (operational data, not code) diff --git a/UMSETZUNGSPLAN.md b/UMSETZUNGSPLAN.md new file mode 100644 index 0000000..378afcc --- /dev/null +++ b/UMSETZUNGSPLAN.md @@ -0,0 +1,600 @@ +# DataMetric360 — Umsetzungsplan + +**Stand: 2026-08-11.** Dieser Plan führt von heute (fertiges HA-Panel, fertige Datenschicht, +halbfertiger Design-Entwurf) bis zur fertigen DataMetric360-App inklusive aller offenen Punkte aus +`AGENTS.md`. Er ist bewusst kleinteilig geschrieben, damit jede Phase auch von einem schwächeren +KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann. + +**Beschlossene Rahmenbedingungen (nicht neu diskutieren):** +- Alle Daten liegen in Home Assistant. Es gibt **kein eigenes Backend** — die App spricht direkt + mit der HA-REST-/WebSocket-API und ruft die vorhandenen `pyscript.audi_dashboard_*`-Services auf. +- **Kein Electron.** Fürs iPhone wird die Web-App mit **Capacitor** verpackt (nur Sideload, nie + App Store). Optionaler Zwischenschritt: als PWA auf den Homescreen (Phase 10). +- Die App ersetzt später das HA-Panel; bis dahin bleibt das Panel unangetastet in Betrieb. + +--- + +## Für das ausführende Modell: Arbeitsregeln + +1. **Lies zuerst:** `AGENTS.md` (Regeln + Stand), dann die Phase, die du bearbeitest — nichts + anderes. Hole dir Detailwissen erst, wenn ein Schritt es verlangt (die Schritte nennen die + Quelldatei). +2. **Eine Phase pro Session.** Arbeite die Schritte in Reihenfolge ab. Jeder Schritt hat eine + Prüfung („✅ Fertig wenn"). Erfülle sie, bevor du weitergehst. Wenn eine Prüfung fehlschlägt, + behebe das, bevor du den nächsten Schritt beginnst. +3. **Nach jeder abgeschlossenen Phase:** Haken in diesem Plan setzen, betroffene Checkboxen in + `AGENTS.md` abhaken, „Last updated" in `AGENTS.md` aktualisieren (englisch!), committen mit + deutscher Commit-Message. +4. **Harte Verbote:** + - Audi-Assets (Schriften, Ringe, Typenschilder) niemals nach `design-system/` kopieren oder in + irgendetwas einbauen, das veröffentlicht/hochgeladen wird. Quelle für die App: + `homeassistant/www/` und `design/uploads/`. + - Keine Schriftgewichte ≥ 600, keine Schatten, keine Verläufe. Nur Gewichte 300/400. + - `homeassistant/` nicht umbauen (nur die explizit genannten Pflege-Schritte in Phase 2). + - Keine zusätzlichen npm-Pakete einführen, die der Plan nicht nennt, ohne es zu begründen und + in `AGENTS.md` zu dokumentieren. + - Home Assistant niemals direkt ins Internet stellen. Nur die in Phase 12 beschriebenen Wege. +5. **Sprache:** Code-Bezeichner, Kommentare, UI-Texte, Commits auf Deutsch (Ausnahme: `AGENTS.md` + englisch; `design-system/` behält englische Props). +6. **Zahlenformat** immer `de-DE` (1.234,5). Übernimm die Helfer `de()`/`eur()` aus dem alten + Panel (`homeassistant/www/audi-dashboard-app.js`), statt eigene zu erfinden. + +## Phasenübersicht und Abhängigkeiten + +| Phase | Inhalt | Hängt ab von | Braucht Hardware/Extern? | +|---|---|---|---| +| 1 | Arbeitsumgebung herstellen und verifizieren | — | nein | +| 2 | Pflege HA-Panel (Doku-Drift, Härtung) | — | nein | +| 3 | Design-Entwurf vervollständigen | — | Claude Design (Besitzer) | +| 4 | Workspace + React-Gerüst in `companion-app/` | 1 | nein | +| 5 | App-Shell: Theme, Router, responsives Layout | 4 | nein | +| 6 | Onboarding + Datenanbindung + Offline-UX | 5 | nein | +| 7 | Alle Screens umsetzen | 3, 6 | nein | +| 8 | Audi-Assets einbauen | 7 | nein | +| 9 | Tests (authentifiziert + Komponenten) | 6 | nein | +| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet | +| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 7–10 | HA-Produktivinstanz | +| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer | +| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** | + +Phasen 2, 3 und 12 (Teilschritte) sind unabhängig und können vorgezogen werden. Phase 13 ist die +einzige, die zwingend auf Hardware wartet. + +--- + +## Phase 1 — Arbeitsumgebung herstellen und verifizieren ✅ ERLEDIGT (2026-08-11) + +> Ergebnis: design-system baut und besteht 21/21, companion-app typecheckt und besteht 7/7. +> Die verlorene Testinstanz wurde als reproduzierbares Skript neu aufgebaut — siehe +> `testumgebung/` (Abweichung vom Plan, bewusst: das Original ging genau deshalb verloren, +> weil es nur als Anleitung existierte). + +**Ziel:** Beide npm-Pakete bauen, beide Smoke-Tests laufen, die HA-Testinstanz ist erreichbar. + +1. Node-Version prüfen: `node --version` — muss ≥ 20 sein (companion-app nutzt + `--experimental-strip-types`, ab Node 22 stabil; wenn < 20: mit nvm/Volta Node 22 installieren). +2. `cd design-system && npm install && npm run build` + ✅ Fertig wenn: `design-system/dist/index.js` und `dist/styles.css` existieren, Build ohne Fehler. +3. `npm run smoke` (im selben Ordner) + ✅ Fertig wenn: 21/21 Fälle „ok" melden. +4. `cd ../companion-app && npm install && npm run typecheck` + ✅ Fertig wenn: `tsc --noEmit` fehlerfrei durchläuft. +5. HA-Testinstanz prüfen: `curl -s -o /dev/null -w '%{http_code}' http://localhost:18123/api/` — + erwartet: `401` (API da, Token fehlt — das ist richtig so). Wenn keine Antwort: Der + Docker-Container heißt `audi_ha_test` (`docker start audi_ha_test`). Existiert er nicht, siehe + `homeassistant/README.md` (Testbericht-Abschnitt) für den Aufbau; notfalls Phase-1-Schritt 6 + überspringen und in Phase 9 nachholen. +6. `npm run smoke` in `companion-app/` + ✅ Fertig wenn: 7/7 Prüfungen grün. + +**Abschluss Phase 1:** nichts committen (nur `node_modules`/`dist`, beides gitignored). + +--- + +## Phase 2 — Pflege des bestehenden HA-Panels ✅ ERLEDIGT (2026-08-11) + +> Doku-Drift behoben, `profil_lesen()` gehärtet und an der Testinstanz belegt. +> Offen bleibt nur, was Betriebsdaten sind: Fahrzeugfotos und `steuer.faellig`. + +**Ziel:** Doku stimmt wieder mit dem Code überein; ein Crash-Risiko ist beseitigt. Reine Pflege — +**keine** Funktionsänderungen. + +1. **Statistik-Behauptung korrigieren** (3 Stellen, gleiche Falschaussage „zeigt Beispielzahlen"): + - `homeassistant/www/audi-dashboard-app.js` Kopfkommentar Zeilen ~13–15 + - `homeassistant/README.md` Zeile ~99 + - `homeassistant/INSTALL.md` Zeile ~204 + Neue Aussage sinngemäß: „Die Statistik-Seite berechnet echte Werte aus Fahrten und + Tankvorgängen (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)." +2. **INSTALL.md Schritt 4** (Zeilen ~106–109): falsche Variablennamen ersetzen. + Falsch: `DOORS_SENSOR`, `WINDOWS_SENSOR`, `LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`. + Richtig (siehe `homeassistant/pyscript/modules/einstellungen.py`): `TUER_SENSOREN`, + `FENSTER_SENSOREN`, `TUERSCHLOSS_SENSOREN` (je 4er-Listen) und `BATTERIE_SENSOR` (einzeln). +3. **README.md:** Erwähnung der „97-%-Volltankungsregel" (~Zeile 110) streichen (Regel wurde + entfernt, siehe Kommentar `pyscript/belegverarbeitung.py:18-20`); in der Dateiübersicht die + fehlenden Skripte ergänzen: `tankerkennung.py`, `batterieverlauf.py`, `bilderverwaltung.py`, + `backup.py`, `updateverwaltung.py`. +4. **Obsoleten Kommentar entfernen:** `pyscript/belegverarbeitung.py:41` — den Zusatz + „TODO: Datei ablegen" streichen (Datei existiert längst), Konstante selbst unverändert lassen. +5. **`profil_lesen()` härten** (`pyscript/modules/profil.py`, ~Zeile 51–55): fehlende oder + nicht parsebare `fahrzeugprofil.json` abfangen. Verhalten: Fehler ins Log (`log.error` ist in + pyscript global verfügbar), Rückgabe `None`; Aufrufer prüfen. Vorher alle Aufrufer suchen + (`grep -rn "profil_lesen" homeassistant/pyscript/`) und sicherstellen, dass jeder mit `None` + umgehen kann — wo nicht, dort eine frühe Rückkehr einbauen. **pyscript-Eigenheit beachten:** + Datei-I/O nur über `task.executor(io.open, …)`-Muster wie im Bestand, kein nacktes `open()`. + ✅ Fertig wenn: `python3 -c "import ast; ast.parse(open('homeassistant/pyscript/modules/profil.py').read())"` + fehlerfrei ist und jeder Aufrufer den `None`-Fall behandelt. +6. **Bewusst NICHT tun:** Swipe-Delete-Fallback, Popup-Tastaturzugang, Leaflet-Selbsthosting + (Audit-Reste) — Entscheidung laut Audit: kommt in DataMetric360, nicht ins alte Panel. + RAM-only-Zustände (Fahrtstart, Tank-Tiefststand) ebenfalls nicht anfassen — wird mit dem + FMM003-Umstieg hinfällig. +7. Committen: „Doku an Codestand angleichen und profil_lesen gegen fehlende Datei härten". + `AGENTS.md`: die erledigten Punkte in Block C abhaken. + +Offen bleiben in Block C nur: Fahrzeugfotos hochladen + `steuer.faellig` setzen (macht der +Besitzer in der App/UI, kein Code). + +--- + +## Phase 3 — Design-Entwurf vervollständigen ⏭️ ÜBERSPRUNGEN (2026-08-11) + +> Entscheidung des Besitzers: Die Screens wurden direkt aus den Views des alten +> Panels abgeleitet, statt auf den Claude-Design-Entwurf zu warten. Dieser +> Abschnitt bleibt stehen, falls der Entwurf später auf den Stand der +> Umsetzung gebracht werden soll. + +**Ziel:** Der Entwurf zeigt das richtige Fahrzeug und alle Seiten, die das alte Panel hat. +Diese Phase läuft im Claude-Design-Projekt +(https://claude.ai/design/p/c28a8d4d-ec4e-4178-9e49-ab5b90c02097) — der Besitzer stößt sie an; +ein Agent kann den Auftragstext vorbereiten und den Export danach einpflegen. + +1. **Korrektur Modell:** überall „Audi RS 4 Avant competition" statt „RS 6 Avant"; Typenschild + `rs4` (negative/positive) statt `rs6`. +2. **Fehlende Seiten ergänzen** (16 Stück; Vorlage ist jeweils die View im alten Panel — + Funktionsnamen aus `homeassistant/www/audi-dashboard-app.js`, Beschreibung in + `SPECIFICATION.md` §3 „Views"): + | # | Seite | Vorlage (alte View) | + |---|---|---| + | 1 | Fahrzeugstatus-Detail (12-Punkte Türen/Fenster/Schlösser) | `vSicherheit()` | + | 2 | Fahrzeugdaten/Technik/Ausstattung | `vIdent()` | + | 3 | Batterieverlauf (SVG-Kurve, SoC/SoH) | `vBatterieverlauf()` | + | 4 | Service (Termine, Terminanfrage, Servicebuch-Liste) | `vService()` | + | 5 | Werkstatt bearbeiten | `vWerkstatt()` | + | 6 | Servicebuch-Eintrag (ansehen/bearbeiten/neu) | `vSbuch(i)` | + | 7 | Versicherung & Steuer (Hub) | `vVers()` | + | 8 | Beitrag bearbeiten | `vBeitrag()` | + | 9 | Vertragsdetails | `vVertragsdetails()` | + | 10 | Schutzbrief (nur lesen) | `vSchutz()` | + | 11 | Notrufnummern bearbeiten | `vNotrufBearbeiten()` | + | 12 | Kfz-Steuer (Ansicht + Bearbeiten) | `vSteuer()`/`vSteuerBearbeiten()` | + | 13 | Reifen (Sätze, km-Zähler, Wechsel, Drehmoment) | `vReifen()` | + | 14 | Fahrt-Detail (Karte, Daten, Bearbeiten) | `vTrip(id)` | + | 15 | Tankvorgang-Detail (inkl. Beleg-Upload/-Ersetzen) | `vFill(id)` | + | 16 | Einstellungen in voller Tiefe (Fahrzeug-Setup, Fotos, SmartDeal, Pausenzeit, Export/Import, Backup, Version/Update) | `vEinst()` | + Für jede Seite beide Layoutvarianten (schmal mit Tab-Leiste unten, breit ≥1000px mit + Seitenleiste) — wie im Brief `DESIGN_BRIEF_DATAMETRIC360.md` gefordert. +3. **Export einpflegen:** kompletten Ordner `design/` durch den neuen Export ersetzen + (`design/README.md` sagt selbst: „wird beim nächsten Export komplett ersetzt"), README-Abschnitt + „Stand dieses Exports" aktualisieren. + ✅ Fertig wenn: `grep -ci "rs 6\|rs6" design/DM360.dc.html` = 0 und alle 16 Seiten im Export + auffindbar sind. +4. Committen; `AGENTS.md` Block A Punkt 1 abhaken. + +**Wichtig:** Phase 7 kann pro Screen schon vor Abschluss dieser Phase beginnen — die 8 vorhandenen +Hauptscreens sind entworfen; die 16 Unterseiten folgen dann dem gleichen Muster. + +--- + +## Phase 4 — Workspace und React-Gerüst ✅ ERLEDIGT (2026-08-11) + +**Ziel:** `companion-app/` ist ein lauffähiges Vite+React+TS-Projekt, das `@audi-dash/ui` als +echte Dependency nutzt. Die bestehende Datenschicht (`src/api/`) bleibt unverändert liegen. + +1. **npm-Workspace-Root anlegen** — neue Datei `package.json` im Repo-Root: + ```json + { + "name": "audi-app", + "private": true, + "workspaces": ["design-system", "companion-app"] + } + ``` + Dazu Repo-Root-`.gitignore` um `node_modules/` ergänzen (falls nicht abgedeckt). +2. **Dependencies in `companion-app/package.json` ergänzen** (bestehende Felder beibehalten!): + `dependencies`: `react ^18`, `react-dom ^18`, `@audi-dash/ui` (Versionsangabe `*` — kommt über + den Workspace); `devDependencies` zusätzlich: `vite`, `@vitejs/plugin-react`, + `@types/react`, `@types/react-dom`. Scripts ergänzen: `"dev": "vite"`, + `"build": "vite build"`, `"preview": "vite preview"`. +3. `npm install` **im Repo-Root** ausführen (verlinkt design-system in die companion-app). + Danach einmal `npm run build --workspace design-system` (die App importiert aus `dist/`). +4. **Vite-Konfiguration** `companion-app/vite.config.ts`: + ```ts + import { defineConfig } from "vite" + import react from "@vitejs/plugin-react" + export default defineConfig({ + plugins: [react()], + server: { port: 5173 }, + build: { outDir: "dist", target: "es2022" }, + }) + ``` +5. **tsconfig anpassen:** Das bestehende `companion-app/tsconfig.json` ist auf die reine + Datenschicht zugeschnitten (`noEmit`, sehr streng — so lassen). Ergänzen: `"jsx": "react-jsx"`, + `"lib": ["ES2022", "DOM", "DOM.Iterable"]` und `"moduleResolution": "bundler"`, falls nicht + gesetzt. Die strengen Flags (`exactOptionalPropertyTypes` usw.) NICHT abschwächen. +6. **Einstieg anlegen:** + - `companion-app/index.html` — Minimal-HTML mit `
` 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, Navigation, responsives Layout ✅ ERLEDIGT (2026-08-11) + +**Ziel:** Navigationsgerüst, das beide Layouts beherrscht (schmal: Tab-Leiste unten; breit ≥1000px: +Seitenleiste), mit Tag/Nacht-Theme und Zurück-Navigation. Noch ohne echte Daten. + +**Vorbild für Verhalten:** das alte Panel (`audi-dashboard-app.js`) — flacher Routenzustand, +`ZURUECK`-Tabelle. Vorbild fürs Aussehen: `design/DM360.dc.html` (dort existiert der +`wide`-Umschalter mit 240px-Seitenleiste bereits). + +1. **Kein Router-Paket installieren.** Eigener flacher Zustand wie im alten Panel: + `src/navigation.ts` mit `type Route = { name: RouteName; id?: string }`, React-State im + App-Root, `geheZu(name, id?)`, plus `ZURUECK`-Tabelle (aus dem alten Panel übernehmen und um + die neuen Routen ergänzen; 3-stufige Verschachtelung `schutz → vertragsdetails → vers` beachten). +2. **Theme:** `src/theme.ts` — Zustand `"nacht" | "tag"`, Persistenz in `localStorage` + (Schlüssel `dm360.theme`), Initialwert: gespeicherter Wert, sonst `prefers-color-scheme`, + Standard `nacht`. Gesetzt wird ausschließlich `data-theme` auf dem `ads-root`-Element. + `localStorage`-Zugriffe in try/catch (Muster aus dem alten Panel, Private-Mode-Browser). +3. **Layout:** `src/Shell.tsx` — CSS-Grid/Flex mit Breakpoint bei 1000px + (`window.matchMedia("(min-width: 1000px)")` + Listener): + - schmal: Inhalt + `TabBar`-Komponente (aus `@audi-dash/ui`) unten, `env(safe-area-inset-*)` + als Padding (iPhone-Notch). + - breit: 240px-Seitenleiste links (Navigationspunkte + Ringe-Platzhalter oben), Inhalt rechts; + Listen-Detail nebeneinander wo der Entwurf es vorsieht. + Die 5 Hauptbereiche: Übersicht, Mein Audi, Fahrten, Statistik, Tanken. Einstellungen über + Zahnrad oben rechts (nicht in der Tab-Leiste) — wie im alten Panel und im Entwurf. +4. **Platzhalter-Screens:** je Hauptbereich eine Datei unter `src/screens/` (z. B. + `Uebersicht.tsx`), die vorerst nur Titel per `ads-eyebrow`/`Tile` zeigt. +5. ✅ Fertig wenn: Im Browser (a) bei < 1000px die Tab-Leiste unten erscheint und alle 5 Bereiche + wechselbar sind, (b) bei ≥ 1000px stattdessen die Seitenleiste erscheint, (c) der Theme-Wechsel + sichtbar Tag/Nacht umschaltet und einen Reload überlebt. +6. Committen: „companion-app: App-Shell mit responsivem Layout, Navigation und Theme". + +--- + +## Phase 6 — Onboarding, Datenanbindung, Offline-UX ✅ ERLEDIGT (2026-08-11) + +**Ziel:** Die App verbindet sich echt mit Home Assistant über die vorhandene Datenschicht. + +**Die Datenschicht ist fertig — nur benutzen, nicht neu bauen.** Einstieg: +`companion-app/src/api/index.ts` (`DataMetricApi`). Sie liefert: REST-Reads +(`profilLesen`, `fahrtenLesen`, `tankvorgaengeLesen`, `fahrzeugstatusLesen`), Live-Updates +(`HassLive` mit Reconnect), Schreibzugriffe über die Offline-Queue (`profilSchreiben`, +`belegHochladen`, `fahrtLoeschen`, `tankvorgangLoeschen`, `jetztAktualisieren`) und +Zugangsdaten-Verwaltung (`umgebung.ts`). + +1. **Ersteinrichtungs-Screen** (`src/screens/Einrichtung.tsx`) nach dem Entwurf in + `design/DM360.dc.html` (dort der `isSetup`-Block): Felder Server-Adresse + Zugangs-Token, + Knopf „Verbinden" ruft `verbindungPruefen()` aus der Datenschicht; Fehler unterscheiden nach + `ApiFehler.istAnmeldeproblem` (Token falsch) vs. `istNetzproblem` (Adresse/Netz) und in + verständlichem Deutsch anzeigen. QR-Knopf: vorerst ausblenden oder deaktiviert mit Hinweis + (kommt mit Capacitor, Phase 10). Bei Erfolg Zugangsdaten über die `Ablage` der Datenschicht + speichern und in die App wechseln. +2. **Datenkontext:** `src/datenkontext.tsx` — ein React-Context, der beim Start `DataMetricApi` + instanziiert, initial alle vier Reads lädt, sich bei `aufZustand()` für Live-Updates + registriert und `{profil, fahrten, tankvorgaenge, status, verbindung}` bereitstellt. + **Übernahme-Pflicht aus dem alten Panel:** die Umrechnungen `profilZuConfig()`/`profilZuCar()` + (inkl. SmartDeal-Gültigkeit: aktiv wenn `laeuft_ab` fehlt oder ≥ heute, Ablaufdatum + **einschließlich**) aus `audi-dashboard-app.js` nach TypeScript portieren — Logik exakt + beibehalten. +3. **Offline-UX** (Entwurf: Offline-Marker in der Kopfzeile): + - Verbindungszustand aus `aufVerbindung()` der Datenschicht → dezenter „Offline"-Hinweis im + Kopf, Daten bleiben sichtbar. + - Warteschlange aus `aufAenderung()` → sichtbare Zahl wartender Änderungen („2 Änderungen + warten auf Übertragung"), wie im Design-Brief gefordert. +4. **Stale-Anzeige:** `last_updated` des Fahrzeugstatus mitführen (Muster: Staleness-Indikator des + alten Panels). +5. ✅ Fertig wenn: Gegen `audi_ha_test` (Token siehe Phase 9 Schritt 1): Einrichtung mit falschem + Token zeigt Anmeldefehler, mit richtigem Token lädt die App Profil + Status; HA-Container + stoppen → Offline-Marker erscheint; Container starten → verschwindet wieder (Reconnect testet + die Backoff-Logik der Datenschicht). +6. Committen; `AGENTS.md` Block A Punkte Onboarding/Offline teilweise abhaken (QR bleibt offen). + +--- + +## Phase 7 — Alle Screens umsetzen ✅ ERLEDIGT (2026-08-11) + +> Alle 21 Seiten stehen. Die Entwurfsvorlage kam für die 16 Unterseiten aus den +> Views des alten Panels statt aus Claude Design (Entscheidung des Besitzers, +> 2026-08-11) — Phase 3 ist damit gegenstandslos, solange der Entwurf nicht +> nachgezogen werden soll. + +**Ziel:** Funktionsgleichheit mit dem alten Panel plus die neuen Live-Ansichten. Pro Screen: +Entwurf aus `design/` nachbauen (mit `@audi-dash/ui`-Komponenten), Logik aus der alten View +portieren, an den Datenkontext anschließen. + +**Reihenfolge und Zuordnung** (Logik-Vorlage = Funktion in `audi-dashboard-app.js`; +Datenquelle = Feld im Datenkontext): + +| Screen | Logik-Vorlage | Datenquelle / Services | Hinweise | +|---|---|---|---| +| Übersicht | `vHome()` | status, profil, fahrten, tankvorgaenge | Foto, Status „sicher abgestellt", Reichweite, km, nächster Service, Teaser letzte Fahrt/Tankung; Pull-to-Refresh → `jetztAktualisieren()` | +| Fahrten-Liste | `vTrips()` | fahrten | Jahr→Monat-Akkordeon (`Accordion`); Lösch-Aktion: **nicht nur Swipe** — zusätzlich „…"-Menü pro Zeile (behebt Audit-Finding barrierefreies Löschen); manuelles Anlegen (`vTripsFormular()`) über Service `audi_dashboard_fahrt_manuell_anlegen` | +| Fahrt-Detail | `vTrip(id)` | fahrten | Karte: Leaflet **lokal bündeln** (`npm i leaflet`, Audit-Finding CDN), Tile-Layer nach Theme; **kein `fakeTrack()` portieren** — ohne echte Route nur Start/Ende-Marker zeigen, mit ehrlichem Leerhinweis | +| Tanken-Liste | `vFuel()` | tankvorgaenge | volumengewichteter Durchschnittspreis; Lösch-„…"-Menü wie bei Fahrten | +| Tankvorgang-Detail | `vFill(id)` | tankvorgaenge | Beleg-Upload: Datei → Base64 → `belegHochladen()`; Ergebnis kommt asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis` (Muster im alten Panel: `belegErgebnisVerarbeiten()`) | +| Statistik | `vStat()` | fahrten, tankvorgaenge | Rechenfunktionen (`fahrtenSeit`, `tankSeit`, `verbrauch`, `istNachtZeit`) 1:1 portieren | +| Mein Audi (Hub) | `vAudi()` | profil | Fotogalerie + Kachel-Links auf die Unterseiten | +| Fahrzeugstatus-Detail | `vSicherheit()` | status | 16-Punkte-Liste; `ok === null` heißt „unbekannt", nie grün raten | +| Fahrzeugdaten | `vIdent()` | profil (technik, ausstattung) | statisch aus dem Profil | +| Batterieverlauf | `vBatterieverlauf()` | eigener Read der Entität `pyscript.audi_dashboard_batterieverlauf` | SVG-Chart portieren (Pinch/Pan/Tap); Leerzustand prominent — Sensor ist derzeit unbesetzt | +| Service + Werkstatt + Servicebuch | `vService()`, `vWerkstatt()`, `vSbuch()` | profil.service | Öl-/Service-Prognose (`oelwechselPrognose()`, `intervalle()`, `termine()`) exakt portieren; ICS-Export (`ics()`-Helfer) übernehmen | +| Versicherung/Steuer-Gruppe | `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`, `vNotrufBearbeiten()`, `vSteuer()` | profil.versicherung, profil.steuer | reine Formular-/Anzeige-Seiten; Schreiben immer als ganzes Profil über `profilSchreiben()` (es gibt keinen Feld-einzeln-Service!) | +| Reifen | `vReifen()` | profil.reifen + Entitäten `pyscript.reifen_*` | Wechsel über Service `audi_dashboard_reifen_wechseln` (nur `"sommer"`/`"winter"`); Wechseldatum → ICS | +| Einstellungen | `vEinst()` | profil, alle | volle Tiefe: Fahrzeug-Setup, Theme, Fotos (Upload über `audi_dashboard_bild_hochladen`, nur die 10 erlaubten Dateinamen!), SmartDeal, Pausenzeit, Profil-Export/Import, Backup, CSV-Export, Version | +| **Neu: Live-Fahrt** | — (neu) | status (künftig FMM003-Felder) | Entwurf `screen === 'live'`: Karte, Geschwindigkeit groß, Strecke/Dauer; bis Phase 13 hinter einem Feature-Schalter `LIVE_VERFUEGBAR = false` in einer zentralen Konfigurationsdatei — Screen bauen, Schalter erst mit echten Daten umlegen | +| Leerzustände | — | — | für Fahrten/Tanken/Statistik je einen freundlichen „noch keine Daten"-Zustand (Design-Brief), wird real gebraucht: beide JSONL sind aktuell leer | + +**Regeln für alle Screens:** +- Nur `@audi-dash/ui`-Komponenten + Tokens (`var(--…)`), keine neuen Hex-Farben, keine neuen + Komponenten ohne sie zuerst in `design-system/` anzulegen (dann dort mit CSS+Preview, danach + hier verwenden). +- Destruktive Aktionen immer mit Bestätigung; Fehler sichtbar in rot inline (Muster altes Panel). +- Jede Zahl durch `de()`/`eur()`. +- Nach jedem Screen: `npm run typecheck` + Sichtprüfung schmal UND breit. + +✅ Fertig wenn: jeder Screen der Tabelle existiert, mit Testdaten befüllbar ist und in beiden +Layouts bedienbar aussieht wie der Entwurf. Committen pro Screen-Gruppe (z. B. „Fahrten-Screens"), +nicht ein Riesencommit. + +--- + +## Phase 8 — Audi-Assets einbauen ✅ ERLEDIGT (2026-08-11) + +**Ziel:** Die App sieht aus wie das Original — Schriften, Ringe, Typenschilder. + +1. Quellen: Schriften aus `design/uploads/*.ttf` (oder die woff2-Base64-Blöcke aus + `homeassistant/www/audi-dashboard.css`), Ringe `design/assets/audi-rings-{white,black}.svg`, + Typenschilder aus `homeassistant/www/badges/` (alle 14). +2. Ablage: `companion-app/src/assets/audi/` + `@font-face`-Deklarationen in einer eigenen + `src/audi-schrift.css` (Familien exakt wie im Bestand: `"Audi Type"` 400, `"Audi Type Wide"` + 300/400, `"Audi Type Extended"` italic). Der `--font-stack` wird per CSS-Variable auf dem + `ads-root` überschrieben — `design-system/`-Dateien bleiben unberührt. +3. Typenschild-Auswahl nach Modell aus dem Profil (Logik aus `vEinst()`/Badge-Mapping des alten + Panels übernehmen; Theme-abhängig positive/negative-Variante). +4. **Kontrolle:** `git log --stat` des Commits zeigt KEINE Änderung unter `design-system/`; + Gewichte ≥ 600 kommen nirgends vor (`grep -rn "font-weight" companion-app/src | grep -vE "300|400"` + liefert nichts). +5. Committen; `AGENTS.md` Block A Assets-Punkt abhaken. Hinweis im Commit: Repo bleibt privat + (Lizenz). + +--- + +## Phase 9 — Tests ✅ ERLEDIGT (2026-08-11) + +> 90 Unit- und Rendertests, dazu 9 Prüfungen gegen die laufende Instanz. + +**Ziel:** Die ungeteste Hälfte der Datenschicht ist abgedeckt; die Kern-Rechenlogik hat Unit-Tests. + +1. **Token für die Testinstanz erzeugen:** `audi_ha_test` im Browser öffnen + (http://localhost:18123), anmelden, Profil → Sicherheit → langlebiges Zugriffstoken erstellen. + Token NUR in `companion-app/.env.local` ablegen (`VITE_TEST_TOKEN=…`), Datei in `.gitignore` + aufnehmen. **Niemals committen.** +2. **Authentifizierter Smoke** (`scripts/smoke-auth.ts`, Aufbau wie `scripts/smoke.ts`): mit Token + (a) `zustandLesen("pyscript.audi_dashboard_profil")` liefert Daten, (b) `dienstAufrufen` mit + einem harmlosen Service (`audi_dashboard_jetzt_aktualisieren`) gibt 200, (c) Queue-Round-Trip: + Eintrag einstellen bei gestopptem Container, Container starten, Queue leert sich. Script in + `package.json` als `smoke:auth`; ohne gesetztes Token bricht es mit klarer Meldung ab + (Exit-Code 0, „übersprungen"). +3. **Unit-Tests** mit `vitest` (einzige neue Dev-Dependency): portierte Rechenlogik testen — + Statistik (`verbrauch`, `istNachtZeit`, Zeiträume), Service-Prognose (`oelwechselPrognose`: + Neustart nach Ölwechsel, 180-Tage-Gewichtung, Kappung), SmartDeal-Gültigkeit (Ablauftag + einschließlich!), `de()`/`eur()`-Formatierung. Testdaten als Fixtures aus realistischen + JSONL-Zeilen (Schema: `SPECIFICATION.md` §5). +4. ✅ Fertig wenn: `npm run smoke:auth` 3/3 grün (mit Token), `npx vitest run` grün, und beide in + `companion-app/README.md` dokumentiert sind. +5. Committen; `AGENTS.md` Block A Test-Punkt abhaken. + +--- + +## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11) + +> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und +> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt +> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView. +> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das +> angeschlossene iPhone und die Apple-ID des Besitzers. + +**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor +(Keychain + QR-Scan). + +**Stufe 1 — PWA (kein neues Werkzeug):** +1. `companion-app/public/manifest.webmanifest` (Name „DataMetric360", `display: "standalone"`, + Themenfarbe `#161b23`, neutrale Icons 192/512px — NICHT die Audi-Ringe als App-Icon, der + Homescreen ist „außen"; neutrales DM360-Monogramm erzeugen) + Verweis im `index.html`. +2. Erreichbarkeit fürs Handy: solange Phase 12 nicht fertig ist, über Tailscale + (`npm run build`, Ausgabe z. B. über HA `/local/` oder `vite preview --host` im LAN testen). +3. ✅ Fertig wenn: „Zum Home-Bildschirm" auf dem iPhone die App randlos startet und Login + + Datenanzeige funktionieren. + +**Stufe 2 — Capacitor:** +4. `npm i -D @capacitor/cli && npm i @capacitor/core @capacitor/ios @capacitor/android` + (im Workspace für companion-app), `npx cap init DataMetric360 app.datametric360 --web-dir dist`, + `npx cap add ios`, `npx cap add android`. +5. **Sichere Token-Ablage:** Capacitor-Secure-Storage-Plugin (z. B. + `@aparajita/capacitor-secure-storage`) als neue `Ablage`-Implementierung; über den vorhandenen + Hook `ablageSetzen()` der Datenschicht einhängen, wenn `umgebungErkennen()` „capacitor" meldet. + Browser-Fall bleibt unverändert `BrowserAblage`. +6. **QR-Scan:** Barcode-Plugin (`@capacitor-mlkit/barcode-scanning`) + Kamera-Berechtigung; + Gegenstück: kleine statische Seite unter HA `/local/dm360-qr.html`, die den Token **clientseitig** + (Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt. Inhalt des QR: JSON + `{"url": "...", "token": "..."}`. Wenn das zusammen > 1 Tag Aufwand wird: weglassen — + manuelles Einfügen ist die beschlossene, ausreichende Lösung. +7. **iOS-Sideload:** `npx cap open ios`, in Xcode Signing mit eigener Apple-ID, aufs Gerät bauen. + ⚠️ Entscheidungspunkt für den Besitzer: mit kostenlosem Apple-Konto läuft die Signatur nach + **7 Tagen** ab (App neu aufspielen); ein bezahltes Entwicklerkonto (99 €/Jahr) macht 1 Jahr. + Bei 7-Tage-Schmerz ist die PWA-Stufe die Alltagslösung, Capacitor das Extra für Keychain/QR. +8. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und + neu installieren → Token weg; Backup/Restore-Verhalten notieren), QR-Einrichtung funktioniert + oder ist dokumentiert entfallen. +9. Committen; `AGENTS.md` Block A Punkte QR/Secure-Storage abhaken. + +--- + +## Phase 11 — HA-Einbettung und Ablösung des alten Panels + +**Ziel:** Die neue App läuft im HA-Seitenmenü; das alte Panel ist archiviert. + +1. **Build in HA ausliefern:** `npm run build`, Ausgabe nach `/config/www/dm360/` der + Produktivinstanz (Weg analog `update.ps1`; das Script um diesen Ordner erweitern). +2. `configuration.yaml`: `panel_iframe`-Eintrag (Titel „Mein Audi", `mdi:car-sports`, + URL `/local/dm360/index.html`). Der `panel_custom`-Block des alten Panels bleibt zunächst + parallel bestehen (zweiter Menüpunkt „Mein Audi (alt)"). +3. **Parallelbetrieb mindestens 2 Wochen:** beide Panels zeigen dieselben Daten (gleiche + Entitäten/Services — Abweichungen sind Portierungsfehler; prüfen: Übersichtszahlen, eine + Fahrt anlegen/löschen, ein Beleg-Upload). +4. **Ablösung:** alten `panel_custom`-Block entfernen; Dateien NICHT löschen, sondern + verschieben: `homeassistant/www/audi-dashboard-app.js` → `homeassistant/archiv/` (plus + Panel-Stub und CSS), README-Hinweis dort ablegen. Beschlossene Regel: **archivieren, nie + löschen** (`COMPANION_APP_ARCHITECTURE.md` §1). +5. ✅ Fertig wenn: Neues Panel im HA-Menü, altes entfernt aber archiviert, `SPECIFICATION.md` + bekommt eine Kopfnotiz „beschreibt das archivierte Panel; aktuell ist companion-app/". +6. Committen; `AGENTS.md` Block D abhaken, Statustabelle umstellen. + +--- + +## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy 🟡 VORBEREITET + +> Fertige Freigabeliste samt Prüfbefehlen: `homeassistant/REVERSE_PROXY.md`. +> Ausführung braucht Cloudflare-Konto und die Nameserver-Umstellung. + +**Ziel:** Die App funktioniert von unterwegs (Mobilfunk), ohne dass HA selbst erreichbar ist. +Referenz: `COMPANION_APP_ARCHITECTURE.md` §4/§5. Teilweise Besitzer-Aufgaben (Konten/DNS). + +1. **[Besitzer] Nameserver umstellen:** `datametric360.app` bei all-inkl auf die + Cloudflare-Nameserver zeigen lassen (Cloudflare-Konto → Site hinzufügen → „Full setup"; + Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.app` die + Cloudflare-Server liefert. +2. **Hostnamen festlegen** (Empfehlung aus dem Architekturdokument übernehmen): App unter + `https://datametric360.app`, API unter `https://api.datametric360.app` — getrennter + API-Hostname hält die Allowlist übersichtlich. Entscheidung in `AGENTS.md` protokollieren. +3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, zwei öffentliche Hostnamen: + `datametric360.app` → Reverse-Proxy-Port (statische App-Dateien + nichts weiter), + `api.datametric360.app` → Reverse-Proxy-Port (API-Pfade). Kein Router-Port wird geöffnet. +4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (Standard-Empfehlung; Traefik nur, falls NPM + an Grenzen stößt — Entscheidung dokumentieren). Allowlist ausschließlich: + - `GET /api/states/pyscript.audi_dashboard_*` und `GET /api/states/pyscript.reifen_*` + - `POST /api/services/pyscript/audi_dashboard_*` + - `GET /api/` (Verbindungsprüfung) und der WebSocket-Pfad `/api/websocket` + Alles andere (v. a. `/auth`, `/lovelace`, `/config`, `/api/config`) wird geblockt. + ⚠️ Ehrliche Einschränkung dokumentieren: `/api/websocket` lässt sich nicht pfadgenau + beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht bleibt der LLAT + (ohne Token keine Verbindung); das ist die im Architekturdokument akzeptierte Abwägung. +5. **Prüfen von außen** (Mobilfunk, VPN aus): App lädt, Login klappt, `curl` auf einen + Nicht-Allowlist-Pfad (z. B. `/auth/authorize`) liefert 403/404; HA-Port 8123 ist von außen + nicht erreichbar. +6. ✅ Fertig wenn: Schritt 5 vollständig besteht. Die endgültige Allowlist wortwörtlich in + `COMPANION_APP_ARCHITECTURE.md` §5.1 eintragen (dort als offener Punkt vorgemerkt) und in + `AGENTS.md` Block B abhaken. + +Schritte 2–4 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden. + +--- + +## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi + +> **Diese Phase ist gegenstandslos.** Die FMM003-Hardware ist seit 2026-08-13 in Betrieb, aber über +> den in `AGENTS.md` (§ FMM003/Datenpfad) beschriebenen Weg: **flespi (natives Codec8/TCP, IMEI- +> Auth), nicht MQTT/Mosquitto.** Die Schritte unten (eigene CA, Mosquitto-Add-on, Zertifikate, +> `mosquitto_sub`-Mitschnitt) beschreiben die zuerst geplante, dann verworfene Variante — stehen +> nur noch als Entscheidungsprotokoll. `homeassistant/FMM003_MAPPING.md` (unten referenziert) +> wurde beim Merge 2026-08-13 deshalb bewusst nicht übernommen. Maßgeblich ist ausschließlich +> `AGENTS.md`. + +**Ziel (nicht mehr aktuell):** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über +die Zündung. Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder +simulieren.** + +1. **Mosquitto-Add-on** in HA installieren, Benutzer für das Gerät anlegen. +2. **Zertifikate:** kleine eigene CA (drei `openssl`-Befehle: CA-Schlüssel+Zertifikat, + Server-Zertifikat für den Broker, Client-Zertifikat fürs Gerät); Broker auf 8883/TLS; + CA-/Client-Dateien im Teltonika Configurator unter *Security* hinterlegen. Ohne Zertifikate + verweigert der FMM003 MQTT. +3. **[Besitzer] Broker-Erreichbarkeit entscheiden:** Portfreigabe 8883 (einfach; TLS+Login + davor) **oder** VPS-Broker mit Mosquitto-Bridge über Tailscale (keine Freigabe, mehr Aufwand). + Entscheidung + Begründung in `COMPANION_APP_ARCHITECTURE.md` §5.2a und `AGENTS.md` nachtragen. +4. **Gerät konfigurieren:** Firmware-Version notieren (Codec JSON ist firmwareabhängig!); + *System → Data Protocol → Codec JSON*; *GPRS → MQTT* mit Broker-IP/Port/Login. +5. **Erste echte Nachricht mitschneiden:** `mosquitto_sub -h -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). diff --git a/companion-app/.gitignore b/companion-app/.gitignore index b518b92..f879a22 100644 --- a/companion-app/.gitignore +++ b/companion-app/.gitignore @@ -2,3 +2,9 @@ node_modules/ dist/ *.local .DS_Store + +# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit +# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind +# getrackt. +ios/ +android/ diff --git a/companion-app/README.md b/companion-app/README.md index 0cb91d5..ae33d4f 100644 --- a/companion-app/README.md +++ b/companion-app/README.md @@ -1,81 +1,76 @@ -# DataMetric360 — companion app +# companion-app — DataMetric360 -Private vehicle app for one car. Ships three ways from one codebase: native iOS and Android via -Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the -decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md). +Private Fahrzeug-App für einen Audi RS 4 Avant competition. Läuft als Web-App +im Browser, als PWA auf dem Homescreen, später über Capacitor nativ auf +iOS/Android und eingebettet als Iframe im Home-Assistant-Dashboard. -## Status +**Kein eigenes Backend.** Die App spricht direkt mit der REST- und +WebSocket-Schnittstelle von Home Assistant und ruft dieselben +`pyscript.audi_dashboard_*`-Dienste auf wie das bestehende Panel. Alle Daten +liegen auf dem eigenen Server; das Gerät hält nur den Zugangstoken und einen +Zwischenspeicher für die Offline-Anzeige. -**Data layer only.** No UI yet — the screens come from the Claude Design draft -([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top -of `design-system/`'s React components once that draft settles. This package was written first on -purpose: how the app talks to Home Assistant doesn't depend on what the screens look like, so it -survives every design iteration untouched. +Architektur: `../COMPANION_APP_ARCHITECTURE.md` · Umsetzungsschritte: +`../UMSETZUNGSPLAN.md` · Projektstand: `../AGENTS.md` -Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage -adapter, and the Audi brand assets (fonts/rings/badges — those come from `homeassistant/www/` at -implementation time, never into `design-system/`, see the licence note in the architecture doc). +## Loslegen -## Layout - -``` -src/api/ -├── types.ts HA state shapes + domain types (Fahrt, Tankvorgang, …) + the entity-ID table -├── umgebung.ts runtime detection (capacitor/iframe/browser), credential storage, URL helpers -├── rest.ts REST client — replaces hass.states / hass.callService -├── live.ts WebSocket client — push updates, auto-reconnect with backoff -├── warteschlange.ts offline queue for writes made without a connection -└── index.ts DataMetricApi — ties the three together, exposes the domain operations -scripts/smoke.ts verification against a running HA instance +```bash +npm install # im Repo-Wurzelverzeichnis, nicht hier (npm-Workspace) +npm run build:ds # Design-System bauen, die App importiert aus dessen dist/ +npm run dev --workspace datametric360 ``` -Comments are in German, matching the rest of the project. +Beim ersten Start fragt die App nach Server-Adresse und Zugangstoken. Für die +Entwicklung setzt `../testumgebung/aufsetzen.sh` eine vollständige +Home-Assistant-Instanz mit dem echten Backend auf. -## What replaces what +## Aufbau -The old panel got a `hass` object injected by `panel_custom`. That object only exists inside the HA -frontend, which is exactly why the old panel can't run as a standalone app. The mapping: - -| Old panel | Here | +| Ordner | Inhalt | |---|---| -| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. | -| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods | -| automatic re-render on state push | `live.aufZustand(...)` | +| `src/api/` | Datenschicht: REST, WebSocket mit Wiederverbindung, Offline-Warteschlange, Zugangsdaten | +| `src/daten/` | Fachlogik: Profil-Umrechnung, Statistik, Service-Prognose, Datenkontext | +| `src/screens/` | Die 21 Seiten der App | +| `src/stile/` | Layout, Bildschirmbausteine, Audi-Schrift | +| `src/assets/audi/` | Schrift, Vier-Ringe, Typenschilder — **lizenzpflichtig, siehe unten** | +| `src/tests/` | Testaufbau und Beispieldaten | -Same entities, same pyscript services, same backend files written — only the transport changes. +Gestaltung kommt vollständig aus `@audi-dash/ui` (`../design-system/`). Neue +Komponenten entstehen dort, nicht hier. -Every **write** goes through the queue rather than straight to REST. That's what makes offline edits -behave the same as online ones, just delayed: a receipt photographed in a dead zone is persisted and -sent when the connection returns, surviving an app restart in between. - -## Commands - -Node is installed at `C:\Program Files\nodejs` but is **not on PATH** — prefix it: +## Prüfen ```bash -$env:Path = "C:\Program Files\nodejs;" + $env:Path +npm run typecheck # TypeScript, sehr streng (exactOptionalPropertyTypes u. a.) +npm run test # 90 Tests: Rechenlogik, Layoutwechsel, alle Seiten gerendert +npm run smoke # Adressbildung und Endpunkte, ohne Token +npm run smoke:auth # gegen die laufende Testinstanz, mit Token +npm run build # Produktionsbündel ``` -```bash -npm run typecheck -``` +`smoke:auth` liest Adresse und Token aus `.env.local` (schreibt +`../testumgebung/aufsetzen.sh`, gitignored). Ohne die Datei überspringt der +Test sich selbst, statt fehlzuschlagen. -```bash -npm run smoke -``` +Die Rendertests decken alle 21 Seiten in beiden Layouts ab — schmal mit +Tab-Leiste, breit mit Seitenleiste. -`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a -different base URL as the first argument. +## Rechtlicher Hinweis -## Verification status (2026-08-10) +`src/assets/audi/` enthält die Hausschrift Audi Type, das Vier-Ringe-Zeichen +und die Modell-Typenschilder. Diese sind lizenz- beziehungsweise +markenrechtlich geschützt und ausschließlich für diese eine private, +unveröffentlichte Installation freigegeben (`../bauauftrag.md` §8/§12). -`tsc --noEmit` clean under `strict` plus `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. -All 7 smoke checks pass against the running test container, covering URL normalisation, the -http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401, -and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow -is built around. +Daraus folgt: **Die App wird nur seitlich installiert, nie in einem App Store +veröffentlicht**, und dieses Repository bleibt privat. `../design-system/` +kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird — +dort niemals Markendateien ablegen. -**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real -round-trip. Those need a Long-Lived Access Token, which is a deliberate manual step (see the LLAT -provisioning decision in the architecture doc). To do it: create a token in the HA profile page, then -extend `scripts/smoke.ts` with authenticated cases. +## Noch offen + +- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore) +- QR-Einrichtung als Alternative zum Einfügen des Tokens +- Live-Ansicht der laufenden Fahrt: gebaut, aber über + `src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert diff --git a/companion-app/capacitor.config.ts b/companion-app/capacitor.config.ts new file mode 100644 index 0000000..7604cff --- /dev/null +++ b/companion-app/capacitor.config.ts @@ -0,0 +1,44 @@ +import type { CapacitorConfig } from "@capacitor/cli" + +/** + * Capacitor-Konfiguration für die native Hülle. + * + * Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind + * ausschließlich für diese eine private Installation freigegeben. + * + * Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in + * Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10. + */ +const konfiguration: CapacitorConfig = { + appId: "app.datametric360", + appName: "DataMetric360", + webDir: "dist", + server: { + // https, damit die App als sicherer Ursprung gilt — sonst verweigern + // Browser-Bausteine wie die Zwischenablage ihren Dienst. + androidScheme: "https", + iosScheme: "https", + }, + ios: { + // Die App zeichnet bis unter die Statusleiste und hält den sicheren + // Bereich selbst über env(safe-area-inset-*) frei. + contentInset: "never", + backgroundColor: "#161b23", + }, + android: { + backgroundColor: "#161b23", + }, + plugins: { + // Leitet fetch() über die native HTTP-Schicht statt über die WebView. + // + // Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle + // liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an + // Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie + // ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine + // CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am + // Server. Die WebSocket-Verbindung läuft unberührt weiter. + CapacitorHttp: { enabled: true }, + }, +} + +export default konfiguration diff --git a/companion-app/index.html b/companion-app/index.html new file mode 100644 index 0000000..8862b87 --- /dev/null +++ b/companion-app/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + DataMetric360 + + +
+ + + diff --git a/companion-app/package.json b/companion-app/package.json index f5337e3..765b1dd 100644 --- a/companion-app/package.json +++ b/companion-app/package.json @@ -2,14 +2,38 @@ "name": "datametric360", "version": "0.1.0", "private": true, - "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.", + "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Datenschicht plus Oberfläche auf Basis von @audi-dash/ui.", "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "typecheck": "tsc --noEmit", - "smoke": "node --experimental-strip-types scripts/smoke.ts" + "smoke": "node --experimental-strip-types scripts/smoke.ts", + "smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts", + "test": "vitest run" + }, + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@audi-dash/ui": "*", + "@capacitor/android": "^8.5.0", + "@capacitor/core": "^8.5.0", + "@capacitor/ios": "^8.5.0", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { + "@capacitor/cli": "^8.5.0", + "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.12", "@types/node": "^24.13.3", - "typescript": "^5.7.2" + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vitest": "^2.1.8" } } diff --git a/companion-app/public/apple-touch-icon.png b/companion-app/public/apple-touch-icon.png new file mode 100644 index 0000000..29cdfe1 Binary files /dev/null and b/companion-app/public/apple-touch-icon.png differ diff --git a/companion-app/public/manifest.webmanifest b/companion-app/public/manifest.webmanifest new file mode 100644 index 0000000..aae28cb --- /dev/null +++ b/companion-app/public/manifest.webmanifest @@ -0,0 +1,33 @@ +{ + "name": "DataMetric360", + "short_name": "DM360", + "description": "Fahrzeugdaten, Fahrtenbuch und Tankvorgänge vom eigenen Server.", + "lang": "de", + "dir": "ltr", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#161b23", + "theme_color": "#161b23", + "icons": [ + { + "src": "./symbol.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "./symbol-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "./symbol-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/companion-app/public/symbol-192.png b/companion-app/public/symbol-192.png new file mode 100644 index 0000000..108e79b Binary files /dev/null and b/companion-app/public/symbol-192.png differ diff --git a/companion-app/public/symbol-512.png b/companion-app/public/symbol-512.png new file mode 100644 index 0000000..8e4b1c2 Binary files /dev/null and b/companion-app/public/symbol-512.png differ diff --git a/companion-app/public/symbol.svg b/companion-app/public/symbol.svg new file mode 100644 index 0000000..764a621 --- /dev/null +++ b/companion-app/public/symbol.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..5efa43b --- /dev/null +++ b/companion-app/src/App.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +import { DataMetricApi, zugangLesen } from "./api" +import { DatenAnbieter, useDaten } from "./daten/DatenKontext" +import { Shell } from "./Shell" +import type { Route, SeitenName } from "./navigation" +import { useTabBeschriftung, useTheme } from "./theme" +import { Einrichtung } from "./screens/Einrichtung" +import { Hinweisleiste } from "./screens/Hinweisleiste" +import { SeiteFuer } from "./screens/register" +import { zugangMerken } from "./screens/zugang" + +import "./stile/rahmen.css" +import "./stile/screens.css" + +type Startzustand = "pruefen" | "einrichten" | "bereit" + +/** + * Wurzel der App. Entscheidet zuerst, ob Zugangsdaten vorliegen — ohne sie + * gibt es nichts anzuzeigen außer der Ersteinrichtung. Danach hängt alles + * unter dem Datenkontext. + */ +export function App() { + const [theme] = useTheme() + const [start, setzeStart] = useState("pruefen") + + // Genau eine Instanz für die Lebensdauer der App: sie hält die + // WebSocket-Verbindung und die Warteschlange. + const api = useMemo(() => new DataMetricApi(), []) + + useEffect(() => { + void zugangLesen().then(async (zugang) => { + // Adresse für Bild-URLs merken, bevor der erste Screen rendert. + if (zugang) await zugangMerken() + setzeStart(zugang ? "bereit" : "einrichten") + }) + }, []) + + return ( +
+ {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/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..99d5b72 --- /dev/null +++ b/companion-app/src/Shell.tsx @@ -0,0 +1,148 @@ +/** + * Rahmen der App: Kopfzeile, Navigation, Inhaltsfläche. + * + * Zwei Layouts aus derselben Zusammensetzung — die entscheidende Anforderung + * aus dem Design-Auftrag: schmal (Handy) mit Tab-Leiste unten, breit (Tablet, + * eingebettet in Home Assistant) mit Seitenleiste links. Der Umschaltpunkt + * liegt bei 1000px. + */ + +import type { ReactNode } from "react" + +import { TabBar } from "@audi-dash/ui" + +import { useBreiterBildschirm } from "./hooks/useBreiterBildschirm" +import { VierRinge } from "./screens/Typenschild" +import type { Hauptbereich, Route, SeitenName } from "./navigation" +import { TITEL, ZURUECK, bereichVon, istHauptbereich } from "./navigation" +import { + SymbolAudi, + SymbolFahrten, + SymbolStatistik, + SymbolTanken, + SymbolUebersicht, + SymbolZahnrad, + SymbolZurueck, +} from "./symbole" + +export interface ShellProps { + route: Route + geheZu: (name: SeitenName, id?: string) => void + tabBeschriftung: boolean + /** Wird über der Kopfzeile eingeblendet (Offline-Hinweis, Warteschlange). */ + hinweis?: ReactNode | undefined + /** Heißt `children`, weil JSX-Verschachtelung genau diesen Namen erwartet. */ + children: ReactNode +} + +const BEREICHE: { key: Hauptbereich; label: string; icon: ReactNode }[] = [ + { key: "home", label: "Übersicht", icon: }, + { 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/api/ablageNativ.ts b/companion-app/src/api/ablageNativ.ts new file mode 100644 index 0000000..6de861e --- /dev/null +++ b/companion-app/src/api/ablageNativ.ts @@ -0,0 +1,88 @@ +/* ================================================================ + Sichere Ablage auf dem Gerät (iOS Keychain / Android Keystore) + ================================================================ + Der Zugangstoken hat vollen Zugriff auf die Home-Assistant-Schnittstelle. + Im Browser bleibt nur localStorage; in der nativen Hülle gehört er in den + Schlüsselbund des Betriebssystems. + + **Noch nicht in Betrieb**: das Plugin ist bewusst keine Abhängigkeit, + solange die Capacitor-Hülle nicht gebaut ist. Der Adapter greift deshalb + defensiv auf die Laufzeit zu, statt zu importieren — fehlt das Plugin, + bleibt es bei der Browser-Variante, und die App läuft trotzdem. + + In Betrieb nehmen (UMSETZUNGSPLAN.md Phase 10 Stufe 2): + npm i @aparajita/capacitor-secure-storage + Danach ruft main.tsx `nativeAblageEinrichten()` auf. */ + +import { BrowserAblage, ablageSetzen, umgebungErkennen, type Ablage } from "./umgebung.ts"; + +/** Form des Plugins, so weit die App sie braucht. */ +interface SicherheitsPlugin { + get(optionen: { key: string }): Promise<{ value: string | null }>; + set(optionen: { key: string; value: string }): Promise; + remove(optionen: { key: string }): Promise; +} + +function pluginHolen(): SicherheitsPlugin | null { + if (typeof window === "undefined") return null; + const global = window as unknown as { + Capacitor?: { Plugins?: Record }; + }; + const plugin = global.Capacitor?.Plugins?.["SecureStorage"]; + if (!plugin) return null; + const kandidat = plugin as Partial; + return typeof kandidat.get === "function" && + typeof kandidat.set === "function" && + typeof kandidat.remove === "function" + ? (plugin as SicherheitsPlugin) + : null; +} + +export class NativeAblage implements Ablage { + readonly #plugin: SicherheitsPlugin; + /* Fällt einzeln auf den Browser zurück, wenn ein Aufruf scheitert - besser + eine Sitzung ohne Schlüsselbund als eine App, die nicht startet. */ + readonly #ersatz = new BrowserAblage(); + + constructor(plugin: SicherheitsPlugin) { + this.#plugin = plugin; + } + + async lesen(schluessel: string): Promise { + try { + const { value } = await this.#plugin.get({ key: schluessel }); + return value ?? null; + } catch { + return this.#ersatz.lesen(schluessel); + } + } + + async schreiben(schluessel: string, wert: string): Promise { + try { + await this.#plugin.set({ key: schluessel, value: wert }); + } catch { + await this.#ersatz.schreiben(schluessel, wert); + } + } + + async loeschen(schluessel: string): Promise { + try { + await this.#plugin.remove({ key: schluessel }); + } catch { + await this.#ersatz.loeschen(schluessel); + } + } +} + +/** + * Schaltet auf die sichere Ablage um, sofern die App nativ läuft und das + * Plugin vorhanden ist. Gibt zurück, ob umgeschaltet wurde — praktisch für + * eine Anzeige in den Einstellungen. + */ +export function nativeAblageEinrichten(): boolean { + if (umgebungErkennen() !== "capacitor") return false; + const plugin = pluginHolen(); + if (!plugin) return false; + ablageSetzen(new NativeAblage(plugin)); + return true; +} diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index b11b69a..0ef1451 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -11,6 +11,7 @@ export * from "./umgebung.ts"; export * from "./rest.ts"; export * from "./live.ts"; export * from "./warteschlange.ts"; +export * from "./ablageNativ.ts"; import { ENTITAETEN } from "./types.ts"; import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts"; @@ -18,6 +19,20 @@ import { HassRest } from "./rest.ts"; import { HassLive } from "./live.ts"; import { Warteschlange } from "./warteschlange.ts"; +/** Eingabefelder für Tankvorgänge. Die Namen sind die des Backends + (belegverarbeitung.py), nicht die des Datensatzes - deshalb "liter" statt + "liters" und "kosten" statt "fuel_total_eur". */ +export interface TankvorgangFelder { + ts?: string; + liter?: number | null; + kosten?: number | null; + km?: number | null; + ersparnis?: number | null; + station?: string | null; + distanz?: number | null; + kraftstoff?: string | null; +} + /** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte Dashboard über hass.callService erledigt hat. */ export class DataMetricApi { @@ -78,6 +93,16 @@ export class DataMetricApi { ); } + /** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */ + fahrtAnlegen(tsStart: string, tsEnde: string, art: "privat" | "arbeitsweg"): Promise { + 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 +112,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/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..2b5136e 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; } @@ -113,8 +123,11 @@ export interface Profil { steuer?: Record; reifen?: Record; service?: Record; - technik?: Record; - ausstattung?: Record; + /* Beide sind Listen von Gruppen, keine Objekte - an der echten + fahrzeugprofil.json abgelesen. Ein früherer Entwurf hatte sie wie die + übrigen Abschnitte als Record deklariert. */ + technik?: unknown[]; + ausstattung?: unknown[]; [weitere: string]: unknown; } 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/assets/audi/audi-rings-black.svg b/companion-app/src/assets/audi/audi-rings-black.svg new file mode 100644 index 0000000..1c3b009 --- /dev/null +++ b/companion-app/src/assets/audi/audi-rings-black.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/companion-app/src/assets/audi/audi-rings-white.svg b/companion-app/src/assets/audi/audi-rings-white.svg new file mode 100644 index 0000000..83a5ca6 --- /dev/null +++ b/companion-app/src/assets/audi/audi-rings-white.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/companion-app/src/assets/audi/audi-sport.svg b/companion-app/src/assets/audi/audi-sport.svg new file mode 100644 index 0000000..07e5f71 --- /dev/null +++ b/companion-app/src/assets/audi/audi-sport.svg @@ -0,0 +1 @@ +AS_RGB_pos_red \ No newline at end of file diff --git a/companion-app/src/assets/audi/schriften/audi-type-400.woff2 b/companion-app/src/assets/audi/schriften/audi-type-400.woff2 new file mode 100644 index 0000000..1943fd8 Binary files /dev/null and b/companion-app/src/assets/audi/schriften/audi-type-400.woff2 differ diff --git a/companion-app/src/assets/audi/schriften/audi-type-extended-400-italic.woff2 b/companion-app/src/assets/audi/schriften/audi-type-extended-400-italic.woff2 new file mode 100644 index 0000000..8bf3232 Binary files /dev/null and b/companion-app/src/assets/audi/schriften/audi-type-extended-400-italic.woff2 differ diff --git a/companion-app/src/assets/audi/schriften/audi-type-wide-300.woff2 b/companion-app/src/assets/audi/schriften/audi-type-wide-300.woff2 new file mode 100644 index 0000000..742d1cb Binary files /dev/null and b/companion-app/src/assets/audi/schriften/audi-type-wide-300.woff2 differ diff --git a/companion-app/src/assets/audi/typenschilder/rs3-negative.svg b/companion-app/src/assets/audi/typenschilder/rs3-negative.svg new file mode 100644 index 0000000..426f19e --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs3-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs3-positive.svg b/companion-app/src/assets/audi/typenschilder/rs3-positive.svg new file mode 100644 index 0000000..97f7c7e --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs3-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs4-negative.svg b/companion-app/src/assets/audi/typenschilder/rs4-negative.svg new file mode 100644 index 0000000..6d913f0 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs4-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs4-positive.svg b/companion-app/src/assets/audi/typenschilder/rs4-positive.svg new file mode 100644 index 0000000..e0d2c06 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs4-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs5-negative.svg b/companion-app/src/assets/audi/typenschilder/rs5-negative.svg new file mode 100644 index 0000000..33c56e5 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs5-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs5-positive.svg b/companion-app/src/assets/audi/typenschilder/rs5-positive.svg new file mode 100644 index 0000000..e88b467 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs5-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs6-negative.svg b/companion-app/src/assets/audi/typenschilder/rs6-negative.svg new file mode 100644 index 0000000..28b6ca5 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs6-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs6-positive.svg b/companion-app/src/assets/audi/typenschilder/rs6-positive.svg new file mode 100644 index 0000000..c07209b --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs6-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs7-negative.svg b/companion-app/src/assets/audi/typenschilder/rs7-negative.svg new file mode 100644 index 0000000..a3ac326 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs7-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/rs7-positive.svg b/companion-app/src/assets/audi/typenschilder/rs7-positive.svg new file mode 100644 index 0000000..ef838c4 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/rs7-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/s3-negative.svg b/companion-app/src/assets/audi/typenschilder/s3-negative.svg new file mode 100644 index 0000000..00935ab --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/s3-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/s3-positive.svg b/companion-app/src/assets/audi/typenschilder/s3-positive.svg new file mode 100644 index 0000000..737f78e --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/s3-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/sq7-negative.svg b/companion-app/src/assets/audi/typenschilder/sq7-negative.svg new file mode 100644 index 0000000..4ada4b9 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/sq7-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/companion-app/src/assets/audi/typenschilder/sq7-positive.svg b/companion-app/src/assets/audi/typenschilder/sq7-positive.svg new file mode 100644 index 0000000..eaaa743 --- /dev/null +++ b/companion-app/src/assets/audi/typenschilder/sq7-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file 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..108ba70 --- /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: [{ gruppe: "Motor", posten: [["Hubraum", "2.9 V6"]] }] }) + const einstellungen = profilZuEinstellungen(roh) + const fahrzeug = profilZuFahrzeug(roh, {}) + einstellungen.kennzeichen = "M XY 9999" + + const neu = zusammenfuehren(roh, einstellungen, fahrzeug) + expect((neu.fahrzeug as Record)["kennzeichen"]).toBe("M XY 9999") + // Abschnitte, die die Oberfläche nicht anfasst, bleiben unverändert. + expect(neu.technik).toEqual([{ gruppe: "Motor", posten: [["Hubraum", "2.9 V6"]] }]) + }) + + it("schreibt den Reifen-Kilometerstand nie mit", () => { + // Das Backend (reifenzaehler.py) schreibt km bei jeder Änderung des + // Kilometerstands fort. Ein im Browser gehaltener älterer Stand darf das + // nicht überschreiben. + const roh = beispielprofil() + const fahrzeug = profilZuFahrzeug(roh, {}) + fahrzeug.reifen.sommer.km = 1 + fahrzeug.reifen.sommer.marke = "Continental" + + const neu = zusammenfuehren(roh, profilZuEinstellungen(roh), fahrzeug) + const saetze = (neu.reifen as Record)["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/daten/service.test.ts b/companion-app/src/daten/service.test.ts new file mode 100644 index 0000000..736b940 --- /dev/null +++ b/companion-app/src/daten/service.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { letzterOelwechsel, oelwechselPrognose } from "./service" +import type { Fahrzeug } from "./profilAdapter" + +function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { + return { + fin: "", + details: "", + erstzulassung: "", + hu: "", + odo, + odoBekannt: true, + tankPct: 0, + reichweite: null, + batteriespannung: null, + gesichert: null, + sicherheitscheck: [], + oelwechselFaelligTs: null, + oelwechselFaelligKm: null, + inspektionFaelligTs: null, + inspektionFaelligKm: null, + smartdeal: { aktiv: false, laeuftAb: null }, + oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 }, + versicherung: {}, + steuer: { faellig: "" }, + reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} }, + service: {}, + technik: {}, + ausstattung: [], + } +} + +const BUCH = [ + { datum: "2026-02-10", km: 40000, art: "Ölwechsel" }, + { datum: "2025-03-05", km: 25000, art: "Inspektion" }, +] + +describe("letzterOelwechsel", () => { + it("findet den jüngsten Ölwechsel und ignoriert andere Arten", () => { + expect(letzterOelwechsel(BUCH)?.datum).toBe("2026-02-10") + }) + + it("erkennt auch die Schreibweise ohne Umlaut", () => { + expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Oelwechsel" }])).not.toBeNull() + }) + + it("liefert null, wenn nie einer eingetragen wurde", () => { + expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Inspektion" }])).toBeNull() + }) +}) + +describe("oelwechselPrognose", () => { + const jetzt = new Date("2026-08-11T12:00:00") + + it("rechnet ab dem letzten Ölwechsel, nicht ab dem ersten Buchungseintrag", () => { + // Seit 10.02. sind 182 Tage vergangen, 6000 km gefahren -> rund 33 km/Tag. + // Ziel ist 40000 + 30000 = 70000, offen sind also 24000 km. + const p = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt) + expect(p).not.toBeNull() + expect(p!.restKm).toBe(24000) + expect(p!.kmProTag).toBeGreaterThan(30) + expect(p!.kmProTag).toBeLessThan(36) + }) + + it("deckelt auf das Zeitlimit, wenn wenig gefahren wurde", () => { + // Nur 100 km seit dem Wechsel: die Kilometerprognose läge Jahre entfernt, + // das Zeitlimit von 24 Monaten greift vorher. + const p = oelwechselPrognose(fahrzeug(40100), BUCH, jetzt) + expect(p!.durchZeitlimit).toBe(true) + expect(p!.datum).toBe("2028-02-10") + }) + + it("meldet Fälligkeit sofort, wenn das Intervall überschritten ist", () => { + const p = oelwechselPrognose(fahrzeug(75000), BUCH, jetzt) + expect(p!.restKm).toBe(0) + expect(p!.datum).toBe("2026-08-11") + }) + + it("nutzt bei eigenem Intervall den kleineren der beiden Werte", () => { + // modus "eigen": min(15000, 30000) = 15000 -> Ziel 55000, offen 9000. + const p = oelwechselPrognose(fahrzeug(46000, "eigen"), BUCH, jetzt) + expect(p!.restKm).toBe(9000) + }) + + it("liefert nichts ohne bekannten Kilometerstand", () => { + const ohne = { ...fahrzeug(0), odoBekannt: false } + expect(oelwechselPrognose(ohne, BUCH, jetzt)).toBeNull() + }) + + it("liefert nichts, wenn nie ein Ölwechsel eingetragen wurde", () => { + expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull() + }) +}) 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/daten/statistik.test.ts b/companion-app/src/daten/statistik.test.ts new file mode 100644 index 0000000..fc8a0ec --- /dev/null +++ b/companion-app/src/daten/statistik.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest" + +import type { Fahrt, Tankvorgang } from "../api" +import { + fahrtenSeit, + istNachtZeit, + jahresauswertung, + langzeitverbrauch, + nachJahrUndMonat, + tankSeit, + zeitraumStart, +} from "./statistik" + +const JETZT = new Date("2026-08-11T12:00:00") + +function fahrt(teil: Partial & { trip_id: string; ts_start: string }): Fahrt { + return { + ts_end: teil.ts_start, + duration_s: 1800, + distance_km: 10, + km_quelle: "odometer", + odo_start: null, + odo_end: null, + art: "privat", + source: "ha", + status: "vollständig", + ...teil, + } +} + +function tankung(teil: Partial & { tank_id: string; ts: string }): Tankvorgang { + return { + liters: 40, + fuel_total_eur: 70, + price_per_l: 1.75, + source: "manual", + status: "vollständig", + ...teil, + } +} + +/** Ortszeit-Datum. toISOString() waere UTC und kippt in Zeitzonen oestlich + von Greenwich auf den Vortag - der Code rechnet aber bewusst lokal. */ +function alsTag(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +describe("zeitraumStart", () => { + it("beginnt das Jahr am 1. Januar", () => { + expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01") + }) + + it("beginnt den Monat am Ersten", () => { + expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01") + }) + + it("beginnt die Woche am Montag, nicht am Sonntag", () => { + // Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08. + const start = zeitraumStart("woche", JETZT) + expect(start.getDay()).toBe(1) + expect(start.getDate()).toBe(10) + }) + + it("beginnt den Tag um Mitternacht", () => { + const start = zeitraumStart("tag", JETZT) + expect(start.getHours()).toBe(0) + expect(start.getDate()).toBe(11) + }) +}) + +describe("istNachtZeit", () => { + it("erkennt eine Spanne über Mitternacht", () => { + expect(istNachtZeit("2026-08-11T23:30:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T03:00:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T12:00:00", "22:00", "06:00")).toBe(false) + }) + + it("erkennt eine Spanne innerhalb eines Tages", () => { + expect(istNachtZeit("2026-08-11T13:00:00", "12:00", "14:00")).toBe(true) + expect(istNachtZeit("2026-08-11T15:00:00", "12:00", "14:00")).toBe(false) + }) + + it("schließt den Beginn ein und das Ende aus", () => { + expect(istNachtZeit("2026-08-11T22:00:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T06:00:00", "22:00", "06:00")).toBe(false) + }) +}) + +describe("fahrtenSeit und tankSeit", () => { + const fahrten = [ + fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }), + fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }), + fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }), + fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }), + fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }), + ] + + it("filtert je Zeitraum", () => { + expect(fahrtenSeit(fahrten, "tag", JETZT).map((f) => f.trip_id)).toEqual(["heute"]) + expect(fahrtenSeit(fahrten, "woche", JETZT).map((f) => f.trip_id)).toEqual([ + "heute", + "diesewoche", + ]) + expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3) + }) + + it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => { + expect(fahrtenSeit(fahrten, "tag", JETZT).some((f) => f.trip_id === "ohnestrecke")).toBe(false) + }) + + it("lässt Tankungen ohne Menge außen vor", () => { + const tanks = [ + tankung({ tank_id: "voll", ts: "2026-08-11T09:00:00" }), + tankung({ tank_id: "offen", ts: "2026-08-11T10:00:00", liters: null }), + ] + expect(tankSeit(tanks, "tag", JETZT).map((t) => t.tank_id)).toEqual(["voll"]) + }) +}) + +describe("langzeitverbrauch", () => { + it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => { + const tanks = [ + tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }), + tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }), + ] + const ergebnis = langzeitverbrauch([], tanks) + expect(ergebnis.km).toBe(600) + expect(ergebnis.liter).toBe(100) + // 100 l auf 600 km sind 16,7 l/100 km — plausibel. + expect(ergebnis.wert).toBeCloseTo(16.67, 1) + expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen") + }) + + it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => { + const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })] + const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })] + const ergebnis = langzeitverbrauch(fahrten, tanks) + expect(ergebnis.km).toBe(500) + expect(ergebnis.basis).toBe("erfasste Fahrten") + }) + + it("meldet unplausible Werte als unbekannt statt als Tatsache", () => { + // 100 l auf 50 km wären 200 l/100 km - unmöglich. + const tanks = [ + tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }), + tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40050 }), + ] + const ergebnis = langzeitverbrauch([], tanks) + expect(ergebnis.wert).toBeNull() + expect(ergebnis.unplausibel).toBe(true) + }) + + it("kommt ohne Tankvorgänge zurecht", () => { + const ergebnis = langzeitverbrauch([], []) + expect(ergebnis.wert).toBeNull() + expect(ergebnis.unplausibel).toBe(false) + expect(ergebnis.bis).toBeNull() + }) +}) + +describe("jahresauswertung", () => { + it("teilt die Strecke in Tag und Nacht sowie in Arbeitsweg und privat", () => { + const fahrten = [ + fahrt({ trip_id: "tag", ts_start: "2026-08-05T12:00:00", distance_km: 100, art: "arbeitsweg" }), + fahrt({ trip_id: "nacht", ts_start: "2026-08-05T23:00:00", distance_km: 40 }), + ] + const a = jahresauswertung(fahrten, [], "22:00", "06:00", JETZT) + expect(a.kmJahr).toBe(140) + expect(a.tagKm).toBe(100) + expect(a.nachtKm).toBe(40) + expect(a.arbeitKm).toBe(100) + expect(a.privatKm).toBe(40) + expect(a.arbeitFahrten).toBe(1) + expect(a.privatFahrten).toBe(1) + }) +}) + +describe("nachJahrUndMonat", () => { + it("gruppiert absteigend, Neuestes zuerst", () => { + const eintraege = [ + { id: "alt", ts: "2025-03-04T10:00:00" }, + { id: "neu", ts: "2026-08-01T10:00:00" }, + { id: "mittel", ts: "2026-02-01T10:00:00" }, + ] + const gruppen = nachJahrUndMonat(eintraege, (e) => e.ts) + expect(gruppen.map((g) => g.jahr)).toEqual([2026, 2025]) + expect(gruppen[0]?.monate.map((m) => m.monat)).toEqual([7, 1]) + expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["neu", "mittel"]) + }) + + it("überspringt unlesbare Zeitangaben, statt daran zu scheitern", () => { + const gruppen = nachJahrUndMonat( + [{ id: "kaputt", ts: "keine-zeit" }, { id: "gut", ts: "2026-08-01T10:00:00" }], + (e) => e.ts, + ) + expect(gruppen).toHaveLength(1) + expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["gut"]) + }) +}) 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/format.test.ts b/companion-app/src/format.test.ts new file mode 100644 index 0000000..ecc0d7d --- /dev/null +++ b/companion-app/src/format.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" + +import { alsCsv } from "./screens/csv" +import { kalenderDatei } from "./screens/kalender" +import { dauer, de, deOderStrich, eur, isoTag, schnittpreis, summe } from "./format" + +describe("Zahlen im deutschen Format", () => { + it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => { + expect(de(48250)).toBe("48.250") + expect(de(1234.5, 1)).toBe("1.234,5") + expect(eur(89.1)).toBe("89,10") + }) + + it("macht aus fehlenden Werten eine Null, nicht NaN", () => { + expect(de(null)).toBe("0") + expect(de(undefined)).toBe("0") + }) + + it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => { + expect(deOderStrich(0)).toBe("0") + expect(deOderStrich(null)).toBe("—") + }) +}) + +describe("dauer", () => { + it("zeigt unter einer Stunde nur Minuten", () => { + expect(dauer(2700)).toBe("45 min") + }) + + it("zeigt darüber Stunden und Minuten zweistellig", () => { + expect(dauer(8040)).toBe("2 h 14 min") + }) + + it("zeigt einen Strich statt „0 min", () => { + expect(dauer(0)).toBe("—") + expect(dauer(null)).toBe("—") + }) +}) + +describe("schnittpreis", () => { + it("gewichtet nach Menge, nicht nach Anzahl der Tankungen", () => { + // 10 l zu 2,00 € und 90 l zu 1,00 €: der Schnitt muss nahe 1,10 liegen, + // nicht bei 1,50 (dem Mittel der Einzelpreise). + const schnitt = schnittpreis([ + { liters: 10, fuel_total_eur: 20 }, + { liters: 90, fuel_total_eur: 90 }, + ]) + expect(schnitt).toBeCloseTo(1.1, 2) + }) + + it("liefert 0 statt einer Division durch null", () => { + expect(schnittpreis([])).toBe(0) + expect(schnittpreis([{ liters: 0, fuel_total_eur: 10 }])).toBe(0) + }) +}) + +describe("summe", () => { + it("überspringt fehlende Werte", () => { + expect(summe([{ a: 1 }, { a: null }, { a: 3 }], (e) => e.a ?? 0)).toBe(4) + }) +}) + +describe("isoTag", () => { + it("gibt das lokale Datum aus, nicht das UTC-Datum", () => { + // 23:30 Ortszeit ist in UTC schon der Folgetag — der Tag darf nicht kippen. + const spaet = new Date(2026, 7, 11, 23, 30) + expect(isoTag(spaet)).toBe("2026-08-11") + }) +}) + +describe("CSV-Ausgabe", () => { + it("trennt mit Semikolon und schreibt Dezimalkomma", () => { + const csv = alsCsv(["Strecke", "Ort"], [[42.5, "München"]]) + expect(csv).toContain("Strecke;Ort") + expect(csv).toContain("42,5;München") + }) + + it("beginnt mit einem BOM, damit Excel die Umlaute richtig liest", () => { + expect(alsCsv(["a"], [])[0]).toBe("") + }) + + it("schützt Felder mit Semikolon oder Anführungszeichen", () => { + const csv = alsCsv(["Text"], [['Er sagte "hallo"; dann ging er']]) + expect(csv).toContain('"Er sagte ""hallo""; dann ging er"') + }) +}) + +describe("Kalenderdatei", () => { + it("erzeugt einen ganztägigen Termin mit Erinnerung zwei Tage vorher", () => { + const ics = kalenderDatei("Ölwechsel", "2027-03-14", "laut Prognose") + expect(ics).toContain("BEGIN:VCALENDAR") + expect(ics).toContain("DTSTART;VALUE=DATE:20270314") + expect(ics).toContain("SUMMARY:Ölwechsel") + expect(ics).toContain("TRIGGER:-P2D") + expect(ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true) + }) + + it("schützt Sonderzeichen im Titel", () => { + expect(kalenderDatei("Service; Öl, Filter", "2027-01-01")).toContain( + "SUMMARY:Service\\; Öl\\, Filter", + ) + }) +}) 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/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/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/main.tsx b/companion-app/src/main.tsx new file mode 100644 index 0000000..bcbd8dc --- /dev/null +++ b/companion-app/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" + +import "@audi-dash/ui/styles.css" +import "./stile/grundlage.css" +import "./stile/audi-schrift.css" +import { nativeAblageEinrichten } from "./api" +import { App } from "./App" + +// In der nativen Hülle den Token in den Schlüsselbund legen statt in +// localStorage. Ohne Hülle passiert hier nichts. +nativeAblageEinrichten() + +const wurzel = document.getElementById("wurzel") +if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html") + +createRoot(wurzel).render( + + + , +) 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/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/Bild.tsx b/companion-app/src/screens/Bild.tsx new file mode 100644 index 0000000..89589ec --- /dev/null +++ b/companion-app/src/screens/Bild.tsx @@ -0,0 +1,38 @@ +/** + * Fahrzeugbild mit Platzhalter. + * + * Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit + * `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string` + * nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert + * es hier einmal. + */ + +import { ImagePlaceholder } from "@audi-dash/ui" + +import { bildUrl } from "./bilder" + +export function Bild({ + datei, + beschreibung, + name, + groesse, +}: { + datei: string | null | undefined + beschreibung: string + name: string + groesse?: "default" | "mini" | "round" +}) { + const url = bildUrl(datei) + // Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten + // Miniaturansicht liefe er über den Rand hinaus. + const mitDateiname = groesse !== "mini" && groesse !== "round" + return ( + + ) +} diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx new file mode 100644 index 0000000..2c40d07 --- /dev/null +++ b/companion-app/src/screens/Einrichtung.tsx @@ -0,0 +1,114 @@ +/** + * Ersteinrichtung: Die App holt ihre Daten vom eigenen Server, also braucht + * sie einmalig dessen Adresse und ein Zugriffstoken. + * + * Entwurf: `design/DM360.dc.html`, Abschnitt `isSetup`. Fehlermeldungen sind + * bewusst nach Ursache getrennt — „Token abgelehnt" und „Server nicht + * erreichbar" verlangen völlig verschiedene Reaktionen des Benutzers. + */ + +import { useState } from "react" + +import { ActionButton, Feld, Tile } from "@audi-dash/ui" + +import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api" +import { zugangMerken } from "./zugang" + +export function Einrichtung({ fertig }: { fertig: () => void }) { + const [adresse, setzeAdresse] = useState("") + const [token, setzeToken] = useState("") + const [laeuft, setzeLaeuft] = useState(false) + const [fehler, setzeFehler] = useState(null) + + const verbinden = async () => { + setzeFehler(null) + if (!adresse.trim()) { + setzeFehler("Bitte die Adresse deines Servers eintragen.") + return + } + if (!token.trim()) { + setzeFehler("Bitte den Zugangs-Token einfügen.") + return + } + + setzeLaeuft(true) + try { + const zugang = { basisUrl: basisUrlNormalisieren(adresse), token: token.trim() } + // Erst prüfen, dann speichern — sonst merkt sich die App Zugangsdaten, + // die nachweislich nicht funktionieren. + await new HassRest(zugang).verbindungPruefen() + await zugangSpeichern(zugang) + await zugangMerken() + fertig() + } catch (ursache) { + if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) { + setzeFehler("Der Server hat den Token abgelehnt. Wurde er vollständig eingefügt?") + } else if (ursache instanceof ApiFehler && ursache.istNetzproblem) { + setzeFehler( + "Der Server ist unter dieser Adresse nicht erreichbar. Stimmen Adresse und Port, und ist das Gerät im richtigen Netz?", + ) + } else { + setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) + } + } finally { + setzeLaeuft(false) + } + } + + return ( +
+
+ 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/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/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/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/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/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/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/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/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/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/Statistik.tsx b/companion-app/src/screens/Statistik.tsx new file mode 100644 index 0000000..7a2bacb --- /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/Typenschild.tsx b/companion-app/src/screens/Typenschild.tsx new file mode 100644 index 0000000..a454945 --- /dev/null +++ b/companion-app/src/screens/Typenschild.tsx @@ -0,0 +1,59 @@ +/** + * Modell-Typenschild und Vier-Ringe-Zeichen. + * + * Beide wechseln mit dem Erscheinungsbild: auf dunklem Grund die negative, + * auf hellem die positive Fassung. Es gibt nur die Modelle, für die ein + * Typenschild vorliegt — für alles andere zeigt die App nichts statt eines + * falschen Schildes. + */ + +import ringeHell from "../assets/audi/audi-rings-white.svg" +import ringeDunkel from "../assets/audi/audi-rings-black.svg" +import { useTheme } from "../theme" + +const SCHILDER = import.meta.glob("../assets/audi/typenschilder/*.svg", { + eager: true, + import: "default", + query: "?url", +}) + +/** + * Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was + * darüber hinaus im Namen steht. + * + * Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst + * steckt schon im Schild — daneben gehört nur, was danach kommt („Avant + * competition"). Sonst stünde „RS 4" zweimal da. + */ +export function schildErkennen( + titel: string, +): { kuerzel: string; zusatz: string } | null { + const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel) + if (!treffer?.[1]) return null + const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase() + return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() } +} + +export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) { + const [theme] = useTheme() + const erkannt = schildErkennen(modell) + if (!erkannt) return null + + const variante = theme === "nacht" ? "negative" : "positive" + const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`] + if (!quelle) return null + + return {modell} +} + +export function VierRinge({ hoehe = 20 }: { hoehe?: number }) { + const [theme] = useTheme() + return ( + Audi + ) +} diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx new file mode 100644 index 0000000..6640833 --- /dev/null +++ b/companion-app/src/screens/Uebersicht.tsx @@ -0,0 +1,221 @@ +/** + * Übersicht — die Startseite. Vorlage: `vHome()` im alten Panel. + * + * Fahrzeugbild, Status „sicher abgestellt", Reichweite und Tankfüllstand, + * Kilometerstand, nächster Service, Vorschau auf letzte Fahrt und letzte + * Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an. + */ + +import { useState } from "react" + +import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui" + +import { useDaten } from "../daten/DatenKontext" +import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format" +import type { SeitenName } from "../navigation" +import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine" +import { Bild } from "./Bild" +import { Typenschild, schildErkennen } from "./Typenschild" + +export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) { + const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } = + useDaten() + const [aktualisiert, setzeAktualisiert] = useState(false) + + if (!fahrzeug || !einstellungen) return null + + // Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild) + // plus der eigens gepflegten Ausführung — so macht es auch das alte Panel. + const schild = schildErkennen(einstellungen.fahrzeugtitel) + const ausfuehrung = einstellungen.ausfuehrung.trim() + // Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition" + // plus Ausführung „competition") — dann nicht doppelt anhängen. + const zusatz = [ + schild?.zusatz, + ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase()) + ? ausfuehrung + : "", + ] + .filter(Boolean) + .join(" ") + .trim() + + const letzteFahrt = fahrten + .slice() + .sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0] + const letzteTankung = tankvorgaenge + .slice() + .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0] + + const sicherheitsstatus = + fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn" + const sicherheitstext = + fahrzeug.gesichert === true + ? "Sicher abgestellt" + : fahrzeug.gesichert === false + ? "Nicht vollständig verschlossen" + : "Zustand unbekannt" + + const aktualisieren = async () => { + setzeAktualisiert(true) + try { + await jetztAktualisieren() + } finally { + setzeAktualisiert(false) + } + } + + return ( + <> +
+ +
+
+ + {/* Neben dem Schild steht nur, was im Namen darüber hinausgeht — + das Modell selbst zeigt bereits das Schild. Ohne erkanntes + Schild tritt der volle Name an dessen Stelle. */} +
+ {schild ? ( + zusatz && {zusatz} + ) : ( + <> + {einstellungen.fahrzeugtitel}{" "} + {zusatz && {zusatz}} + + )} +
+
+ + {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/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/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/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/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 new file mode 100644 index 0000000..5ff424c --- /dev/null +++ b/companion-app/src/screens/register.tsx @@ -0,0 +1,110 @@ +/** + * Zuordnung Route → Seite. Eine Stelle, an der jede Seite eingetragen ist; + * die Shell weiß nichts über Inhalte, die Seiten nichts über Navigation. + */ + +import { useDaten } from "../daten/DatenKontext" +import type { Route, SeitenName } from "../navigation" +import { Batterie } from "./Batterie" +import { Einstellungen } from "./Einstellungen" +import { FahrtDetail } from "./FahrtDetail" +import { Fahrten } from "./Fahrten" +import { Fahrzeugdaten } from "./Fahrzeugdaten" +import { Fahrzeugstatus } from "./Fahrzeugstatus" +import { LiveFahrt } from "./LiveFahrt" +import { MeinAudi } from "./MeinAudi" +import { Reifen } from "./Reifen" +import { Service, Servicebuch, Werkstatt } from "./Service" +import { Statistik } from "./Statistik" +import { TankDetail } from "./TankDetail" +import { Tanken } from "./Tanken" +import { Uebersicht } from "./Uebersicht" +import { + Beitrag, + Notruf, + Schutzbrief, + Steuer, + Versicherung, + Vertragsdetails, +} from "./Versicherung" + +export interface SeitenProps { + route: Route + geheZu: (name: SeitenName, id?: string) => void + tabBeschriftung: boolean + setzeTabBeschriftung: (an: boolean) => void + beiAbmeldung: () => void +} + +export function SeiteFuer(props: SeitenProps) { + const daten = useDaten() + + // Erste Ladung: nichts anzeigen außer einem Hinweis. Ein leeres Gerüst wäre + // schlimmer als ein ehrliches „lädt". + if (!daten.bereit) { + if (daten.ladefehler) { + return ( +
+
Daten nicht erreichbar
+

{daten.ladefehler}

+
+ ) + } + return
Daten werden geladen …
+ } + + const { route, geheZu, tabBeschriftung, setzeTabBeschriftung, beiAbmeldung } = props + + switch (route.name) { + case "home": + return + case "sicherheit": + 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": + return + case "fuel": + return + case "fill": + return + case "stat": + 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/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/audi-schrift.css b/companion-app/src/stile/audi-schrift.css new file mode 100644 index 0000000..05f1c8e --- /dev/null +++ b/companion-app/src/stile/audi-schrift.css @@ -0,0 +1,63 @@ +/* + * Hausschrift Audi Type. + * + * RECHTLICH: Schrift, Ringe und Typenschilder sind lizenz- beziehungsweise + * markenrechtlich geschützt und ausschließlich für diese eine private, + * nicht veröffentlichte Installation freigegeben (bauauftrag.md §8/§12). + * Deshalb wird die App nur seitlich installiert und nie in einen App Store + * gegeben — und deshalb enthält design-system/ diese Dateien bewusst nicht, + * weil es nach außen hochgeladen wird. + * + * Die Dateien stammen aus homeassistant/www/audi-dashboard.css, wo sie als + * base64 eingebettet lagen. Als eigene woff2-Dateien sind sie zwischenspeicherbar, + * statt bei jedem Laden des Stylesheets erneut übertragen zu werden. + * + * Es gibt nur die Schnitte 300 und 400. Niemals fetter setzen — der Browser + * würde die Schrift künstlich verdicken, was sichtbar anders aussieht. + */ + +@font-face { + font-family: "Audi Type"; + src: url("../assets/audi/schriften/audi-type-400.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Audi Type Wide"; + src: url("../assets/audi/schriften/audi-type-wide-300.woff2") format("woff2"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Audi Type Extended"; + src: url("../assets/audi/schriften/audi-type-extended-400-italic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +/* + * Überschreibt den neutralen Ersatzstapel aus @audi-dash/ui. Die Bibliothek + * selbst bleibt unberührt — sie kennt diese Schrift nicht und darf es nicht. + */ +.ads-root { + --font-stack: "Audi Type", Helvetica, Arial, sans-serif; + --font-wide: "Audi Type Wide", "Audi Type", Helvetica, Arial, sans-serif; +} + +/* Große Zahlen (Kilometerstand, Reichweite, Beträge) in der breiten Schnitt- + variante, wie im Panel. */ +.ads-fig, +.dm-detail__zahl { + font-family: var(--font-wide); + font-weight: 300; +} + +.ads-sport { + font-family: "Audi Type Extended", var(--font-stack); + font-style: italic; +} 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/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css new file mode 100644 index 0000000..9e9ca74 --- /dev/null +++ b/companion-app/src/stile/rahmen.css @@ -0,0 +1,209 @@ +/* + * Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche. + * Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui. + */ + +.dm-spalte { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +/* ------------------------------------------------------------- Kopfzeile */ + +.dm-kopf { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3); + border-bottom: 1px solid var(--line); + background: var(--canvas); +} + +.dm-kopf__titel { + flex: 1; + min-width: 0; +} + +.dm-kopf__zurueck, +.dm-kopf__aktion { + flex: none; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--r-pill); + background: transparent; + color: var(--fg2); + cursor: pointer; +} + +.dm-kopf__zurueck:hover, +.dm-kopf__aktion:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-kopf__aktion[aria-current="page"] { + color: var(--red); +} + +/* ---------------------------------------------------------- Inhaltsfläche */ + +.dm-inhalt { + flex: 1; + padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5)); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen. + Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0, + sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt + die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst. + Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */ +.dm-inhalt > * { + flex: none; +} + +.dm-inhalt--breit { + padding: var(--sp-5) 32px 32px; +} + +/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt + untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */ +.dm-raster { + display: grid; + gap: var(--sp-3); + grid-template-columns: 1fr; +} + +@media (min-width: 1000px) { + .dm-raster { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + } + + .dm-raster--drei { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dm-raster__breit { + grid-column: 1 / -1; + } +} + +/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */ +.dm-zweispalter { + display: grid; + gap: var(--sp-4); + grid-template-columns: 1fr; + align-items: start; +} + +@media (min-width: 1000px) { + .dm-zweispalter { + grid-template-columns: minmax(0, 4fr) minmax(0, 5fr); + } +} + +/* ----------------------------------------------------------- Seitenleiste */ + +.dm-seitenleiste { + flex: 0 0 240px; + width: 240px; + height: 100%; + box-sizing: border-box; + border-right: 1px solid var(--line); + padding: 24px 14px 16px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--canvas); +} + +.dm-seitenleiste__marke { + padding: 0 10px var(--sp-4); +} + +.dm-seitenleiste__punkt { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 11px 10px; + border: 0; + border-radius: var(--r-func); + background: transparent; + color: var(--fg2); + font-size: 14px; + font-weight: 300; + text-align: left; + cursor: pointer; +} + +.dm-seitenleiste__punkt:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-seitenleiste__punkt--an { + background: var(--tile); + color: var(--fg); +} + +.dm-seitenleiste__fuss { + margin-top: auto; + padding-top: var(--sp-3); + border-top: 1px solid var(--line); +} + +/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als + Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */ +.dm-brotkrume { + padding: var(--sp-4) 32px 0; +} + +.dm-brotkrume button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--fg2); + font-size: 13px; + font-weight: 300; + cursor: pointer; + padding: 6px 0; +} + +.dm-brotkrume button:hover { + color: var(--fg); +} + +/* ------------------------------------------------------------ Tab-Leiste */ + +.dm-tableiste { + flex: none; + padding-bottom: env(safe-area-inset-bottom, 0px); + border-top: 1px solid var(--line); + background: var(--canvas); +} + +.dm-seitenleiste__marke { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 10px var(--sp-4); +} + +.dm-szene__marke { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; +} diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css new file mode 100644 index 0000000..aa37eee --- /dev/null +++ b/companion-app/src/stile/screens.css @@ -0,0 +1,595 @@ +/* + * Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste, + * Ersteinrichtung, Leerzustände, Ladeanzeige. + */ + +/* -------------------------------------------------------- Eingabefelder */ + +.dm-eingabe, +.dm-auswahl { + font: inherit; + font-weight: 300; + font-size: 16px; + color: var(--fg); + background: none; + border: 0; + text-align: right; + outline: none; + min-width: 120px; + max-width: 60%; + padding: 0; +} + +.dm-eingabe::placeholder { + color: var(--fg3); +} + +.dm-auswahl { + text-align: right; + appearance: none; + cursor: pointer; +} + +.dm-auswahl option { + color: var(--fg); + background: var(--tile); +} + +.dm-notiz { + font: inherit; + font-weight: 300; + font-size: 16px; + color: var(--fg); + background: var(--tile-2); + border: 0; + border-radius: var(--r-func); + padding: 10px 12px; + width: 100%; + min-height: 88px; + resize: vertical; + outline: none; +} + +/* --------------------------------------------------------- Hinweisleiste */ + +.dm-hinweis { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 9px var(--sp-5); + padding-top: calc(env(safe-area-inset-top, 0px) + 9px); + font-size: 12.5px; + font-weight: 300; + color: var(--fg); + background: var(--tile-2); + border-bottom: 1px solid var(--line); +} + +.dm-hinweis--offline { + color: var(--warn); +} + +.dm-hinweis svg { + flex: none; +} + +/* -------------------------------------------------------- Ersteinrichtung */ + +.dm-einrichtung { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + overflow-y: auto; +} + +.dm-einrichtung__karte { + width: 100%; + max-width: 420px; +} + +.dm-einrichtung__titel { + font-size: 28px; + font-weight: 300; + letter-spacing: -0.02em; + margin: 10px 0 12px; +} + +.dm-einrichtung__text { + font-size: 14px; + font-weight: 300; + line-height: 1.55; + color: var(--fg2); + margin: 0 0 22px; + text-wrap: pretty; +} + +.dm-einrichtung__fehler { + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--bad); + margin: 14px 0 0; +} + +.dm-einrichtung__knoepfe { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: var(--sp-4); +} + +.dm-einrichtung__hinweis { + font-size: 11.5px; + font-weight: 300; + line-height: 1.5; + color: var(--fg3); + margin: 18px 0 0; +} + +/* ------------------------------------------------- Leer- und Ladezustände */ + +.dm-leer { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--sp-2); + padding: 44px 24px; + color: var(--fg2); +} + +.dm-leer__titel { + font-size: 16px; + font-weight: 300; + color: var(--fg); +} + +.dm-leer__text { + font-size: 13.5px; + font-weight: 300; + line-height: 1.55; + max-width: 34ch; + margin: 0; + text-wrap: pretty; +} + +.dm-laden { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 44px 24px; + color: var(--fg2); + font-size: 13.5px; + font-weight: 300; +} + +/* ------------------------------------------------------------ Übersicht */ + +.dm-szene { + position: relative; + border-radius: var(--r-tile); + overflow: hidden; + background: var(--tile); +} + +.dm-szene .ads-bild, +.dm-szene img { + width: 100%; + display: block; +} + +.dm-szene__text { + padding: var(--sp-4) var(--sp-4) var(--sp-5); +} + +.dm-tanktext { + display: flex; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-2); + font-size: 12.5px; + font-weight: 300; + color: var(--fg2); +} + +.dm-navikachel__wert { + margin-top: var(--sp-1); + font-size: 15px; + font-weight: 300; + color: var(--fg); +} + +.dm-navikachel__zahl { + font-size: 20px; + font-weight: 300; +} + +.dm-navikachel__wert small, +.dm-wertzeile small { + display: block; + margin-top: 2px; + font-size: 11.5px; + color: var(--fg3); +} + +.dm-aktionen { + display: flex; + justify-content: center; + padding: var(--sp-2) 0 var(--sp-4); +} + +.dm-textknopf { + border: 0; + background: transparent; + color: var(--fg2); + font-size: 13px; + font-weight: 300; + padding: 10px 14px; + border-radius: var(--r-pill); + cursor: pointer; +} + +.dm-textknopf:hover:not(:disabled) { + background: var(--shade); + color: var(--fg); +} + +.dm-textknopf:disabled { + opacity: 0.5; + cursor: default; +} + +/* --------------------------------------------------------- Werteliste */ + +.dm-werteliste { + margin: var(--sp-3) 0 0; +} + +.dm-wertzeile { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-4); + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.dm-wertzeile:last-child { + border-bottom: 0; +} + +.dm-wertzeile dt { + font-size: 13px; + font-weight: 300; + color: var(--fg2); +} + +.dm-wertzeile dd { + margin: 0; + font-size: 14px; + font-weight: 300; + text-align: right; +} + +.dm-abschnitt { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) 2px 0; +} + +.dm-fussnote { + margin: var(--sp-3) 0 0; + font-size: 11.5px; + font-weight: 300; + line-height: 1.5; + color: var(--fg3); + text-wrap: pretty; +} + +/* ------------------------------------------------------ Fahrzeugstatus */ + +.dm-pruefliste { + list-style: none; + margin: var(--sp-3) 0 0; + padding: 0; +} + +.dm-pruefliste__zeile { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.dm-pruefliste__zeile:last-child { + border-bottom: 0; +} + +.dm-pruefliste__label { + flex: 1; + font-size: 14px; + font-weight: 300; +} + +.dm-pruefliste__wert { + font-size: 12.5px; + font-weight: 300; + color: var(--fg2); +} + +.dm-punkt { + flex: none; + width: 9px; + height: 9px; + border-radius: 50%; +} + +.dm-punkt--ok { + background: var(--ok); +} + +.dm-punkt--offen { + background: var(--bad); +} + +/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot, + damit "keine Meldung" nie wie eine Aussage aussieht. */ +.dm-punkt--unbekannt { + border: 1.5px solid var(--fg3); +} + +/* ------------------------------------------------------------ Galerie */ + +.dm-galerie { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: var(--sp-2); + margin-top: var(--sp-3); +} + +/* ------------------------------------------------------- Listenzeilen */ + +.dm-zeile { + display: flex; + align-items: stretch; + gap: 2px; +} + +.dm-zeile__wisch { + flex: 1; + min-width: 0; +} + +/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn + waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */ +.dm-zeile__menue { + flex: none; + width: 44px; + border: 0; + background: transparent; + color: var(--fg3); + border-radius: var(--r-func); + cursor: pointer; +} + +.dm-zeile__menue:hover { + background: var(--shade); + color: var(--fg); +} + +.dm-listenzeile { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + padding: 12px 2px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.dm-listenzeile__haupt { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.dm-listenzeile__titel { + display: flex; + align-items: baseline; + gap: var(--sp-2); + font-size: 16px; + font-weight: 300; +} + +.dm-listenzeile__neben { + font-size: 13px; + color: var(--fg2); +} + +.dm-listenzeile__unter { + font-size: 12px; + font-weight: 300; + color: var(--fg3); + /* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei + wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe, + die man sucht. */ + line-height: 1.4; +} + +/* -------------------------------------------------------- Detailseiten */ + +.dm-detail__kopf { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); + margin: var(--sp-2) 0 var(--sp-3); + flex-wrap: wrap; +} + +.dm-detail__zahl { + font-size: 40px; + font-weight: 300; + line-height: 1; + letter-spacing: -0.02em; +} + +.dm-detail__einheit { + font-size: 15px; + color: var(--fg2); + margin-left: 6px; +} + +.dm-detail__marken { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.dm-karte { + height: 240px; + width: 100%; + border-radius: var(--r-func); + overflow: hidden; + margin-top: var(--sp-3); + background: var(--tile-2); +} + +/* ------------------------------------------------------------ Meldungen */ + +.dm-fehler { + margin: var(--sp-3) 0 0; + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--bad); +} + +.dm-erfolg { + margin: var(--sp-3) 0 0; + font-size: 13px; + font-weight: 300; + line-height: 1.5; + color: var(--ok); +} + +.dm-fussnote--warnung { + color: var(--warn); +} + +/* --------------------------------------------------------- Listen, Text */ + +.dm-liste { + list-style: none; + margin: var(--sp-2) 0 0; + padding: 0; + font-size: 13.5px; + font-weight: 300; + line-height: 1.7; +} + +.dm-liste li { + padding-left: 14px; + position: relative; +} + +.dm-liste li::before { + content: ""; + position: absolute; + left: 0; + top: 0.72em; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--fg3); +} + +.dm-liste__serie { + color: var(--fg2); +} + +.dm-diagramm { + width: 100%; + height: 120px; + margin: var(--sp-3) 0; + display: block; +} + +.dm-knopfreihe { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + margin-top: var(--sp-4); +} + +.dm-waehlen { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + margin-top: var(--sp-3); +} + +.dm-waehlen__knopf { + display: inline-flex; + align-items: center; + padding: 10px 16px; + border: 1px solid var(--line-strong); + border-radius: var(--r-pill); + font-size: 13.5px; + font-weight: 300; + color: var(--fg); + text-decoration: none; +} + +.dm-waehlen__knopf:hover { + background: var(--shade); +} + +/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht + aus dem Blick geraten. */ +.dm-speicherleiste { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) 0; + background: var(--canvas); + border-top: 1px solid var(--line); +} + +.dm-szene__zusatz { + min-width: 0; +} + +/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit + Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */ +.dm-szene__zusatz { + min-width: 0; + white-space: normal; + overflow: visible; + text-overflow: clip; + font-size: 19px; + line-height: 1.2; +} + +/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px), + damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung + innerhalb des Rahmens bleibt. */ +.dm-galerie { + grid-template-columns: repeat(auto-fill, 78px); + justify-content: space-between; + gap: var(--sp-3); +} 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/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 +} 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/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/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/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/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/homeassistant/REVERSE_PROXY.md b/homeassistant/REVERSE_PROXY.md new file mode 100644 index 0000000..3a32b94 --- /dev/null +++ b/homeassistant/REVERSE_PROXY.md @@ -0,0 +1,126 @@ +# Reverse-Proxy: Pfad-Freigabeliste für DataMetric360 + +**Vorbereitung, noch nicht in Betrieb.** Gehört zu Phase 12 des +`../UMSETZUNGSPLAN.md`; die Entscheidung dahinter steht in +`../COMPANION_APP_ARCHITECTURE.md` §4. + +Ziel: Die App erreicht Home Assistant von unterwegs, **ohne dass Home Assistant +selbst im Internet steht**. Dazu ein Cloudflare-Tunnel (baut nur nach außen auf, +kein Port am Router) auf einen Reverse Proxy, der ausschließlich die unten +aufgeführten Pfade durchlässt. + +## Was die App wirklich braucht + +Aus dem tatsächlichen Code der Datenschicht (`companion-app/src/api/`) +abgeleitet, nicht geschätzt: + +| Methode | Pfad | Wofür | +|---|---|---| +| `GET` | `/api/` | Verbindungsprüfung bei der Ersteinrichtung | +| `GET` | `/api/states/pyscript.audi_dashboard_*` | Profil, Fahrten, Tankvorgänge, Status, Batterieverlauf, Belegergebnis, Updatestatus | +| `GET` | `/api/states/pyscript.reifen_*` | Reifensatz-Kilometerstände | +| `POST` | `/api/services/pyscript/audi_dashboard_*` | alle schreibenden Vorgänge | +| `GET` (Upgrade) | `/api/websocket` | Live-Aktualisierung | + +Alles andere wird geblockt, insbesondere `/auth/*`, `/lovelace*`, `/config*`, +`/api/config`, `/api/history*`, `/developer-tools*` und die Oberfläche selbst. + +## Ehrliche Einschränkung + +**Der WebSocket lässt sich nicht pfadgenau beschneiden.** Nach `auth_ok` kann +über `/api/websocket` grundsätzlich jeder Zustand gelesen werden, nicht nur die +`pyscript.*`-Entitäten. Die Freigabeliste ist an dieser Stelle also grobkörniger +als beim REST-Zugriff. + +Was bleibt: Ohne gültigen Token kommt gar keine Verbindung zustande, und ein +verlorenes Gerät wird durch Zurückziehen genau seines Tokens ausgesperrt. Das +ist die im Architekturdokument bewusst akzeptierte Abwägung — sie sollte nur +nicht in Vergessenheit geraten. + +Wer sie nicht eingehen will, hat eine Alternative: den WebSocket weglassen und +die App auf regelmäßiges Abfragen umstellen. Kostet Akku und Datenvolumen, +verkleinert die Angriffsfläche aber auf die exakt aufgeführten REST-Pfade. + +## Nginx Proxy Manager + +Im Add-on unter *Hosts → Proxy Hosts → Edit → Advanced* eintragen. Ziel ist der +interne Name der Home-Assistant-Instanz (im Supervisor-Netz `homeassistant:8123`). + +```nginx +# Reihenfolge zählt: die erlaubenden Blöcke stehen vor dem pauschalen Verbot. + +location = /api/ { + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location ~ ^/api/states/pyscript\.(audi_dashboard_[a-z_]+|reifen_[a-z_]+)$ { + limit_except GET { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location ~ ^/api/services/pyscript/audi_dashboard_[a-z_]+$ { + limit_except POST { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location = /api/websocket { + proxy_pass http://homeassistant:8123; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + include conf.d/include/proxy.conf; +} + +# Die Fotos der Fahrzeuge, die die App anzeigt. Nur Lesen, nur Bilder. +location ~ ^/local/bilder/[a-z0-9_-]+\.(webp|png|jpg|svg)$ { + limit_except GET { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +# Alles Übrige: nicht durchlassen. +location / { + return 404; +} +``` + +Wird die App selbst unter derselben Adresse ausgeliefert, braucht sie einen +eigenen Block auf ihr Verzeichnis — sauberer ist ein getrennter Hostname +(`datametric360.app` für die App, `api.datametric360.app` für die +Schnittstelle), dann bleibt diese Liste unverändert. + +## Nach der Einrichtung prüfen + +Von einem Netz ohne VPN, etwa über Mobilfunk: + +```bash +API=https://api.datametric360.app +T= + +# muss gehen +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \ + "$API/api/states/pyscript.audi_dashboard_profil" + +# muss 404 oder 403 liefern +curl -s -o /dev/null -w '%{http_code}\n' "$API/auth/authorize" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/config" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \ + "$API/api/states/sensor.audi_rs_4_avant_mileage" + +# ohne Token: 401, nicht 200 +curl -s -o /dev/null -w '%{http_code}\n' "$API/api/states/pyscript.audi_dashboard_profil" +``` + +Zusätzlich: Port 8123 darf von außen **gar nicht** antworten. + +## Offen bis zur Einrichtung + +- Nameserver von `datametric360.app` auf Cloudflare umstellen („Full setup") +- Entscheidung Nginx Proxy Manager gegen Traefik (die Liste oben ist für NPM + geschrieben und für Traefik sinngemäß zu übertragen) +- Endgültige Hostnamen festlegen und hier eintragen diff --git a/homeassistant/pyscript/backup.py b/homeassistant/pyscript/backup.py index 4ebf61a..afb421a 100644 --- a/homeassistant/pyscript/backup.py +++ b/homeassistant/pyscript/backup.py @@ -53,6 +53,8 @@ def _backup_jetzt(): def _letztes_backup_vermerken(): p = profil.profil_lesen() + if p is None: + return p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat() profil.profil_schreiben(p) frontend_veroeffentlichung.profil_veroeffentlichen() @@ -69,6 +71,8 @@ def audi_dashboard_backup_jetzt(): @time_trigger("cron(0 4 * * *)") def _automatisches_backup(): p = profil.profil_lesen() + if p is None: + return intervall = p.get("einstellungen", {}).get("backup_intervall", "aus") heute = datetime.date.today() faellig = ( 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 2d7ce9d..b445a4c 100644 --- a/homeassistant/pyscript/fahrterkennung.py +++ b/homeassistant/pyscript/fahrterkennung.py @@ -42,6 +42,8 @@ _fahrt_start_ts = None def _pausenzeit_sekunden(): p = profil.profil_lesen() + if p is None: + return 15 * 60 return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py index a5be5b5..0160b7b 100644 --- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py +++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py @@ -25,7 +25,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 b415c3c..1615d5c 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 56ecd71..cbd212d 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 c8debcf..f62bd32 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. diff --git a/homeassistant/www/dm360-qr.html b/homeassistant/www/dm360-qr.html new file mode 100644 index 0000000..d1189b6 --- /dev/null +++ b/homeassistant/www/dm360-qr.html @@ -0,0 +1,115 @@ + + + + + + DataMetric360 — Gerät verbinden + + + + + +
+

Gerät verbinden

+

+ Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein. + Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet. +

+ + + + + + + + + +
+ +
+ + + + diff --git a/homeassistant/www/dm360-qrcode-lib.js b/homeassistant/www/dm360-qrcode-lib.js new file mode 100644 index 0000000..df13f82 --- /dev/null +++ b/homeassistant/www/dm360-qrcode-lib.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..80eaaf9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6140 @@ +{ + "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": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@audi-dash/ui": "*", + "@capacitor/android": "^8.5.0", + "@capacitor/core": "^8.5.0", + "@capacitor/ios": "^8.5.0", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.0", + "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.12", + "@types/node": "^24.13.3", + "@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" + } + }, + "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/@aparajita/capacitor-secure-storage": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@aparajita/capacitor-secure-storage/-/capacitor-secure-storage-8.0.0.tgz", + "integrity": "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg==", + "license": "MIT", + "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.2", + "@capacitor/ios": "^8.0.2", + "@capacitor/keyboard": "^8.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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 + }, + "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/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", + "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/@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/@capacitor/android": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.0.tgz", + "integrity": "sha512-Rb3prJeQiTp0pQhSSOReJuG9VgibOGMG4ECs+UhMwr6TCCHZ3NCZO811bDA0HxD0xrT/gCrJAsY5yfEATu84JQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz", + "integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.0.tgz", + "integrity": "sha512-rLdzMUM5QV4WITcqoWv04p32i14BXgUH2diqEH6MWQlWaJfiyNrvOyt/+d5vHAfOxOm1klBu637VDEobctlwBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xcode": "^3.0.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", + "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.0.tgz", + "integrity": "sha512-7k1TfohJu5BzRXc1F3g04Q8QQ0fyQgms6h1AvhnHb04VaNr9EdHOx03hL8O+OigEKMQnyJd1ZOjTPPNHCqL5Yw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.5.tgz", + "integrity": "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "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", + "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/@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/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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/@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", + "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/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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", + "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", + "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/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "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/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "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/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", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "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", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/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/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "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 + }, + "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/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", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "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/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.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/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "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/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "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", + "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/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "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/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", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.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/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/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/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "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/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "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/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/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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", + "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", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "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", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/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", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "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", + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "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/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "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/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", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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": { + "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", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "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", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} 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" + } +} 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..0f7ac8c --- /dev/null +++ b/testumgebung/konfiguration.yaml @@ -0,0 +1,170 @@ +# 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: + +# Die App laeuft unter einer anderen Adresse als Home Assistant. Ohne diese +# Freigabe lehnt der Browser jede Anfrage ab, noch bevor sie den Server +# erreicht (CORS). Gilt genauso im Echtbetrieb - siehe +# ../homeassistant/REVERSE_PROXY.md. +http: + cors_allowed_origins: + - http://localhost:4173 # Vorschau des Produktionsbuendels + - http://localhost:5173 # Entwicklungsserver + - capacitor://localhost # native Huelle iOS + - ionic://localhost # native Huelle Android + +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